Avec le développement de Node.js qui bat son plein aujourd'hui, nous pouvons déjà l'utiliser pour faire diverses choses. Il y a quelque temps, le propriétaire d'UP a participé à l'événement Geek Song. Dans cet événement, notre objectif était de créer un jeu qui permette aux "personnes modestes" de communiquer davantage. La fonction principale est l'interaction multijoueur en temps réel basée sur. le concept de Lan Party. Le concours Geek Pine ne dure que 36 heures, pitoyablement courtes, et exige que tout soit rapide et rapide. Dans un tel contexte, les premiers préparatifs semblaient un peu « naturels ». Comme solution pour les applications multiplateformes, nous avons choisi node-webkit, qui est assez simple et répond à nos exigences.
Selon les besoins, nos développements peuvent être réalisés séparément selon les modules. Cet article décrit en détail le processus de développement de Spaceroom (notre framework de jeu multijoueur en temps réel), y compris une série d'explorations et de tentatives, ainsi que la résolution de certaines limitations des plateformes Node.js et WebKit elles-mêmes, et propose des solutions.
Démarrer
Spaceroom en un coup d'oeil
Dès le début, la conception de Spaceroom était définitivement axée sur les besoins. Nous espérons que ce framework pourra fournir les fonctions de base suivantes :
Être capable de distinguer un groupe d'utilisateurs en unités de pièces (ou canaux)
Capable de recevoir des instructions des utilisateurs du groupe de collecte
La synchronisation temporelle entre chaque client peut diffuser avec précision les données de jeu selon l'intervalle spécifié
Peut minimiser l'impact causé par le retard du réseau
Bien sûr, dans la dernière étape du codage, nous avons fourni plus de fonctions pour Spaceroom, notamment la mise en pause du jeu, la génération de nombres aléatoires cohérents entre différents clients, etc. (Bien sûr, celles-ci peuvent être implémentées dans le cadre logique du jeu en fonction des besoins, mais pas nécessairement (vous devez utiliser Spaceroom, un framework qui fonctionne davantage au niveau de la communication).
API
Spaceroom est divisé en deux parties : front-end et back-end. Le travail requis côté serveur comprend la maintenance de la liste des salles et la fourniture des fonctions de création de salles et de jonction de salles. Nos API client ressemblent à ceci :
spaceroom.connect(adresse, rappel) – se connecter au serveur
spaceroom.createRoom(callback) – Créer une salle
spaceroom.joinRoom(roomId) – rejoindre une salle
spaceroom.on(event, callback) – écouter les événements
…
Une fois le client connecté au serveur, il recevra divers événements. Par exemple, un utilisateur dans une salle peut recevoir un événement indiquant qu'un nouveau joueur a rejoint ou que le jeu a commencé. Nous avons donné au client un "cycle de vie", et il sera à tout moment dans l'un des états suivants :
Vous pouvez obtenir l'état actuel du client via spaceroom.state.
L'utilisation du framework côté serveur est relativement simple. Si vous utilisez le fichier de configuration par défaut, vous pouvez simplement exécuter le framework côté serveur directement. Nous avons une exigence de base : le code du serveur peut s'exécuter directement dans le client, sans avoir besoin d'un serveur séparé. Ceux d'entre vous qui ont joué sur PS ou PSP sauront exactement de quoi je parle. Bien entendu, il peut être exécuté sur un serveur dédié, ce qui est naturellement excellent.
La mise en œuvre du code logique est ici simplifiée. La première génération de Spaceroom remplissait la fonction d'un serveur Socket. Elle maintenait une liste de salles, comprenant l'état des salles, et la communication en temps de jeu (collecte de commandes, diffusion de bucket, etc.) correspondant à chaque salle. Pour une implémentation spécifique, veuillez vous référer au code source.
Algorithme de synchronisation
Alors, comment rendre cohérents les éléments affichés entre chaque client en temps réel ?
Cette chose semble intéressante. Réfléchissez bien, de quoi avons-nous besoin que le serveur nous aide à passer ? Il est naturel de penser à ce qui peut provoquer des incohérences logiques entre différents clients : les instructions utilisateur. Puisque les codes qui gèrent la logique du jeu sont tous identiques, dans les mêmes conditions, les résultats du code seront les mêmes. La seule différence réside dans les différentes instructions reçues par les joueurs pendant la partie. Bien entendu, nous avons besoin d’un moyen de synchroniser ces instructions. Si tous les clients peuvent recevoir les mêmes instructions, alors tous les clients peuvent théoriquement avoir les mêmes résultats d'exécution.
Les algorithmes de synchronisation des jeux en ligne sont toutes sortes d'étranges, et leurs scénarios applicables sont également différents. L'algorithme de synchronisation utilisé par Spaceroom est similaire au concept de frame lock. Nous divisons la chronologie en intervalles, et chaque intervalle est appelé un seau. Le bucket est utilisé pour charger les instructions et est géré par le serveur. À la fin de chaque période de compartiment, le serveur diffuse le compartiment à tous les clients. Une fois que le client a obtenu le compartiment, il en récupère les instructions et les exécute après vérification.
Afin de réduire l'impact du retard du réseau, chaque instruction reçue par le serveur du client sera transmise au bucket correspondant selon un certain algorithme. Plus précisément, suivez les étapes suivantes :
.Supposons que order_start soit l'heure d'occurrence de l'instruction portée par l'instruction, et t soit l'heure de début du compartiment où se trouve order_start
Si t delay_time <= l'heure de début du bucket collectant actuellement les instructions, délivrez l'instruction au bucket collectant actuellement les instructions, sinon passez à l'étape 3
Livrez la commande au bucket correspondant à t delay_time
Parmi eux, delay_time est le temps de retard convenu du serveur, qui peut être considéré comme le délai moyen entre les clients. La valeur par défaut dans Spaceroom est de 80 et la valeur par défaut de la longueur du bucket est de 48. À la fin de chaque période de bucket, le délai est de 48. Le serveur diffuse ce compartiment à Tous les clients commencent à recevoir des instructions pour le compartiment suivant. Le client effectue automatiquement un ajustement du temps dans la logique en fonction de l'intervalle de compartiment reçu pour contrôler l'erreur de temps dans une plage acceptable.
Cela signifie que dans des circonstances normales, le client recevra un bucket du serveur toutes les 48 ms. Lorsque le délai de traitement du bucket est atteint, le client le traitera en conséquence. En supposant que le FPS du client = 60, un compartiment sera reçu toutes les 3 images environ et la logique sera mise à jour en fonction de ce compartiment. Si le bucket n'est pas reçu après le dépassement du délai en raison de fluctuations du réseau, le client suspend la logique du jeu et attend. Au sein d'un bucket, les mises à jour logiques peuvent utiliser la méthode lerp.
Dans le cas de delay_time = 80, bucket_size = 48, toute instruction sera retardée d'au moins 96 ms. En modifiant ces deux paramètres, par exemple, dans le cas de delay_time = 60, bucket_size = 32, toute instruction sera retardée d'au moins 64 ms.
Un meurtre provoqué par un minuteur
Dans l'ensemble, notre framework doit disposer d'un minuteur précis lors de son exécution. Effectuez une diffusion par compartiment à intervalle fixe. Bien sûr, nous avons d'abord pensé à utiliser setInterval(), mais la seconde suivante, nous avons réalisé à quel point cette idée n'était pas fiable : le vilain setInterval() semblait avoir une erreur très grave. Et le plus terrible, c’est que chaque erreur s’accumule, entraînant des conséquences de plus en plus graves.
Nous avons donc immédiatement pensé à utiliser setTimeout() pour corriger dynamiquement la prochaine heure d'arrivée afin de maintenir notre logique à peu près stable autour de l'intervalle spécifié. Par exemple, cette fois, setTimeout() est 5 ms de moins que prévu, alors nous le ferons 5 ms plus tôt la prochaine fois. Cependant, les résultats du test ne sont pas satisfaisants et ce n'est pas assez élégant.
Nous devons donc à nouveau changer notre façon de penser. Est-il possible de faire expirer setTimeout() le plus rapidement possible, puis de vérifier si l'heure actuelle atteint l'heure cible. Par exemple, dans notre boucle, utiliser setTimeout(callback, 1) pour vérifier constamment l'heure semble être une bonne idée.
Minuteur décevant
Nous avons immédiatement écrit un morceau de code pour tester notre idée, et les résultats ont été décevants. Dans la dernière version stable de node.js (v0.10.32) et de la plateforme Windows, exécutez ce code :
Après un moment, entrez somme/compte dans la console et vous verrez un résultat similaire à :
Quoi ?! J'ai demandé un intervalle de 1 ms et vous m'avez dit que l'intervalle moyen réel est de 15,625 ms ! Cette photo est tout simplement trop belle. Nous avons fait le même test sur mac et obtenu un résultat de 1,4 ms. Alors on s'est demandé : qu'est-ce que c'est que ça ? Si j'étais un fan d'Apple, je devrais peut-être conclure que Windows est trop nul et abandonner Windows. Mais heureusement, je suis un ingénieur front-end rigoureux, alors j'ai commencé à réfléchir à ce chiffre.
Attendez, pourquoi ce numéro vous semble-t-il si familier ? Le nombre 15,625 ms est-il trop similaire à l'intervalle de minuterie maximum sous Windows ? J'ai immédiatement téléchargé un ClockRes pour le tester, et lorsque je l'ai exécuté sur la console, j'ai obtenu les résultats suivants :
Comme prévu ! En regardant le manuel de node.js, nous pouvons voir cette description de setTimeout :
Le délai réel dépend de facteurs externes tels que la granularité de la minuterie du système d'exploitation et la charge du système.
Cependant, les résultats des tests montrent que ce délai réel correspond à l'intervalle de temporisation maximum (notez que l'intervalle de temporisation actuel du système n'est actuellement que de 1,001 ms), ce qui est de toute façon inacceptable. Notre forte curiosité nous a poussé à examiner le code source. de node.js. Regardez de plus près.
BUG dans Node.js
Je pense que la plupart d'entre vous et moi avons une certaine compréhension du mécanisme de boucle paire de Node.js. En regardant le code source de l'implémentation du timer, nous pouvons à peu près comprendre le principe d'implémentation du timer. boucle de boucle d'événement :
L'implémentation interne de cette fonction utilise la fonction Windows GetTickCount() pour définir l'heure actuelle. En termes simples, après avoir appelé la fonction setTimeout, après une série de difficultés, le timer interne->due sera réglé sur le délai d'expiration de la boucle actuelle. Dans la boucle d'événements, mettez d'abord à jour l'heure actuelle de la boucle via uv_update_time, puis vérifiez si une minuterie a expiré dans uv_process_timers. Si tel est le cas, entrez dans le monde de JavaScript. Après avoir lu l'intégralité de l'article, la boucle d'événements a probablement ce processus :
Mettre à jour l'heure mondiale
Vérifiez le minuteur, si un minuteur expire, exécutez le rappel
Vérifiez la file d'attente des requêtes et exécutez les requêtes en attente
Entrez la fonction d'interrogation pour collecter les événements IO. Si un événement IO arrive, ajoutez la fonction de traitement correspondante à la file d'attente des demandes pour exécution dans la boucle d'événements suivante. Dans la fonction d'interrogation, une méthode système est appelée pour collecter les événements IO. Cette méthode bloquera le processus jusqu'à ce qu'un événement IO arrive ou que le délai d'attente défini soit atteint. Lorsque cette méthode est appelée, le délai d’attente est défini sur l’heure d’expiration la plus récente du minuteur. Cela signifie que la collecte des événements IO est bloquée et que le temps de blocage maximum est l'heure de fin du minuteur suivant.
Code source d'une des fonctions de sondage sous Windows :
En suivant les étapes ci-dessus, en supposant que nous définissons une minuterie avec un délai d'attente = 1 ms, la fonction d'interrogation se bloquera pendant 1 ms maximum, puis reprendra (s'il n'y a aucun événement IO pendant la période). En continuant à entrer dans la boucle d'événements, uv_update_time mettra à jour l'heure, puis uv_process_timers constatera que notre minuterie a expiré et exécutera un rappel. Ainsi, l'analyse préliminaire est que soit il y a un problème avec uv_update_time (l'heure actuelle n'est pas mise à jour correctement), soit la fonction d'interrogation attend 1 ms puis récupère, et il y a quelque chose qui ne va pas avec cette attente de 1 ms.
En recherchant MSDN, nous avons trouvé, à notre grande surprise, une description de la fonction GetTickCount :
La résolution de la fonction GetTickCount est limitée à la résolution de la minuterie système, qui est généralement comprise entre 10 millisecondes et 16 millisecondes.
La précision de GetTickCount est si approximative ! Supposons que la fonction d'interrogation se bloque correctement pendant 1 ms, mais que la prochaine fois que uv_update_time sera exécuté, le temps de boucle actuel n'est pas mis à jour correctement ! Ainsi, notre minuterie n'a pas été considérée comme ayant expiré, donc le sondage a attendu encore 1 ms et est entré dans la boucle d'événement suivante. Ce n'est que lorsque GetTickCount a finalement été mis à jour correctement (soi-disant mis à jour toutes les 15,625 ms) et que l'heure actuelle de la boucle a été mise à jour que notre minuterie a été jugée avoir expiré dans uv_process_timers.
Demandez de l'aide à WebKit
Ce code source de Node.js est très impuissant : il utilise une fonction temporelle de faible précision sans aucun traitement. Mais nous avons tout de suite pensé que puisque nous utilisons Node-WebKit, en plus du setTimeout de Node.js, nous avions également le setTimeout de Chromium. Écrivez un code de test et exécutez-le avec notre navigateur ou Node-WebKit : http://marks.lrednight.com/test.html#1 (Le numéro qui suit # indique l'intervalle qui doit être mesuré) , le résultat est le suivant :
Selon les spécifications HTML5, le résultat théorique devrait être de 1 ms pour les 5 premières fois et de 4 ms pour les résultats suivants. Les résultats affichés dans le scénario de test commencent à partir de la troisième fois, ce qui signifie que les données sur le tableau devraient théoriquement être de 1 ms pour les trois premières fois et que les résultats suivants sont tous de 4 ms. Les résultats comportent une certaine erreur et, selon la réglementation, le plus petit résultat théorique que nous pouvons obtenir est de 4 ms. Même si nous ne sommes pas satisfaits, c'est évidemment bien plus satisfaisant que le résultat de node.js. Tendance de curiosité puissante Jetons un coup d'œil au code source de Chromium pour voir comment il est implémenté. (https://chromium.googlesource.com/chromium/src.git/ /38.0.2125.101/base/time/time_win.cc)
Tout d'abord, pour déterminer l'heure actuelle de la boucle, Chromium utilise la fonction timeGetTime(). En consultant MSDN, vous constaterez que la précision de cette fonction est affectée par l'intervalle de minuterie actuel du système. Sur notre machine de test, il s'agit théoriquement des 1.001ms évoqués plus haut. Cependant, par défaut dans les systèmes Windows, l'intervalle du timer est sa valeur maximale (15,625 ms sur la machine de test), sauf si l'application modifie l'intervalle du timer global.
Si vous suivez l'actualité du secteur informatique, vous devez avoir vu une telle actualité. Il semble que notre Chromium ait réglé l'intervalle de minuterie très petit ! Il semble que nous n’ayons plus à nous soucier des intervalles de minuterie du système ? Ne vous réjouissez pas trop tôt, ce correctif nous donne un coup dur. En fait, ce problème a été résolu dans Chrome 38. Devons-nous utiliser une réparation du précédent Node-WebKit ? Ceci est évidemment inélégant et nous empêche d'utiliser des versions plus performantes de Chromium.
En regardant plus en détail le code source de Chromium, nous pouvons constater que lorsqu'il y a une minuterie et que le délai d'attente de la minuterie est
其中,kMinTimerIntervalLowResMs = 4,kMinTimerIntervalHighResMs = 1。timeBeginPeriod et timeEndPeriod pour Windows 提供的用来修改系统 timer interval 的函数。也就是说L'intervalle de minuterie est de 1 ms. Il s'agit d'un paramètre 4ms. Il s'agit d'un paramètre setTimeout défini par le W3C, qui s'applique à 4ms.气,这个对我们的影响不大。
又一个精度问题
回到开头,我们发现测试结果显示,setTimeout 的间隔并不是稳定在 的,而是在不断地波动。而http://marks.lrednight.com/test.html#48 测试结果也显示,间隔在 48ms and 49ms 之间跳动。原因是,在 Chromium et Node.js 的 event loop 中,等待 IO Les versions Windows s'affichent correctement. Demande d'informations sur requestAnimationFrame Il s'agit de kMinTimerIntervalLowResMs (16 ms) et de kMinTimerIntervalLowResMs (16 ms). L'intervalle de minuterie est de 1 ms et l'intervalle de minuterie est de ±1 ms.时器间隔,运行上面那个#48的测试,max可能会到达48 16=64ms。
Chromium utilise setTimeout pour définir setTimeout(fn, 1) 4ms pour setTimeout(fn, 48) 1ms 左右。于是,我们的心中有了一幅新的蓝图,它让我们的代码看起来像是这样:
Le code ci-dessus nous permet d'attendre un moment où l'erreur est inférieure à bucket_size (bucket_size – écart) au lieu d'égaler directement un bucket_size Même si l'erreur maximale se produit avec un délai de 46 ms, selon la théorie ci-dessus, l'intervalle réel est inférieur à 48 ms. Le reste du temps nous utilisons la méthode d'attente occupée pour nous assurer que notre gameLoop est exécuté à un intervalle suffisamment précis.
Bien que nous ayons résolu le problème dans une certaine mesure en utilisant Chromium, ce n'est évidemment pas assez élégant.
Vous vous souvenez de notre demande initiale ? Notre code côté serveur doit pouvoir s'exécuter indépendamment du client Node-Webkit et s'exécuter directement sur un ordinateur avec un environnement Node.js. Si vous exécutez directement le code ci-dessus, la valeur de l'écart est d'au moins 16 ms, ce qui signifie que toutes les 48 ms, nous devons attendre 16 ms. L'utilisation du processeur ne cesse d'augmenter.
Surprise inattendue
C'est vraiment ennuyeux. Il y a un si gros BUG dans Node.js, et personne ne l'a remarqué ? La réponse nous a vraiment surpris. Ce BUG a été corrigé dans la v.0.11.3. Vous pouvez également voir les résultats modifiés en visualisant directement la branche master du code libuv. La méthode spécifique consiste à ajouter un délai d'attente à l'heure actuelle de la boucle une fois que la fonction d'interrogation attend la fin. De cette façon, même si GetTickCount ne répond pas, après avoir attendu le sondage, nous ajoutons toujours ce temps d'attente. Ainsi, la minuterie peut expirer en douceur.
En d'autres termes, le problème sur lequel il a fallu beaucoup de temps pour travailler a été résolu dans la v.0.11.3. Toutefois, nos efforts ne sont pas vains. Car même si l'influence de la fonction GetTickCount est éliminée, la fonction d'interrogation elle-même est également affectée par le timer système. Une solution consiste à écrire un plug-in Node.js pour modifier l'intervalle de la minuterie système.
Cependant, pour notre jeu cette fois, le paramètre initial est qu'il n'y a pas de serveur. Une fois que le client a créé une salle, celle-ci devient un serveur. Le code du serveur peut s'exécuter dans l'environnement Node-WebKit, de sorte que le problème du minuteur sous les systèmes Windows n'est pas la priorité la plus élevée. D’après la solution que nous avons donnée ci-dessus, le résultat suffit à nous satisfaire.
Fin
Après avoir résolu le problème de la minuterie, il n'y a fondamentalement aucun obstacle à la mise en œuvre de notre framework. Nous fournissons la prise en charge de WebSocket (dans un environnement HTML5 pur) et personnalisons également le protocole de communication pour implémenter une prise en charge de Socket plus performante (dans un environnement Node-WebKit). Bien sûr, les fonctions de Spaceroom étaient relativement rudimentaires au début, mais à mesure que les exigences augmentaient et que le temps augmentait, nous avons progressivement amélioré le cadre.
Par exemple, lorsque nous avons constaté que nous devions générer des nombres aléatoires cohérents dans notre jeu, nous avons ajouté une telle fonction à Spaceroom. Spaceroom distribuera des graines de nombres aléatoires au démarrage du jeu. La Spaceroom du client fournit une méthode pour utiliser le caractère aléatoire de md5 pour générer des nombres aléatoires à l'aide de graines de nombres aléatoires.
Il a l'air très content. En écrivant un tel framework, j'ai également beaucoup appris. Si Spaceroom vous intéresse, vous pouvez également y participer. Je pense que Spaceroom montrera sa force dans davantage d’endroits.