Da sich Node.js heute auf Hochtouren entwickelt, können wir es bereits für eine Vielzahl von Dingen nutzen. Vor einiger Zeit nahm der UP-Besitzer am Geek Song-Event teil. Bei diesem Event wollten wir ein Spiel entwickeln, das es „unterlegenen Leuten“ ermöglicht, mehr zu kommunizieren das Lan-Party-Konzept. Der Geek Pine-Wettbewerb dauert nur erbärmlich kurze 36 Stunden und erfordert, dass alles schnell und zügig abläuft. Unter einer solchen Prämisse wirkten die ersten Vorbereitungen etwas „natürlich“. Als Lösung für plattformübergreifende Anwendungen haben wir uns für Node-Webkit entschieden, das einfach genug ist und unseren Anforderungen entspricht.
Je nach Bedarf kann unsere Entwicklung nach Modulen getrennt durchgeführt werden. In diesem Artikel wird der Prozess der Entwicklung von Spaceroom (unser Echtzeit-Multiplayer-Spiel-Framework) ausführlich beschrieben, einschließlich einer Reihe von Erkundungen und Versuchen, sowie der Lösung einiger Einschränkungen der Node.js- und WebKit-Plattformen selbst und Lösungsvorschlägen.
Erste Schritte
Spaceroom auf einen Blick
Die Gestaltung von Spaceroom war von Anfang an eindeutig bedarfsorientiert. Wir hoffen, dass dieses Framework die folgenden Grundfunktionen bereitstellen kann:
Sie können eine Gruppe von Benutzern in Raumeinheiten (oder Kanälen) unterscheiden
Kann Anweisungen von Benutzern in der Sammlungsgruppe empfangen
Durch die Zeitsynchronisierung zwischen den einzelnen Clients können Spieldaten gemäß dem angegebenen Intervall genau übertragen werden
Kann die durch Netzwerkverzögerungen verursachten Auswirkungen minimieren
Natürlich haben wir in der späteren Phase des Codierens weitere Funktionen für Spaceroom bereitgestellt, darunter das Anhalten des Spiels, die Generierung konsistenter Zufallszahlen zwischen verschiedenen Clients usw. (Natürlich können diese je nach Bedarf im Spiellogik-Framework implementiert werden, aber nicht unbedingt Sie müssen Spaceroom verwenden, ein Framework, das eher auf der Kommunikationsebene funktioniert.
APIs
Spaceroom ist in zwei Teile unterteilt: Front-End und Back-End. Zu den serverseitigen Arbeiten gehören die Pflege der Raumliste sowie die Bereitstellung der Funktionen zum Anlegen von Räumen und zum Verbinden von Räumen. Unsere Client-APIs sehen so aus:
spaceroom.connect(address, callback) – mit dem Server verbinden
spaceroom.createRoom(callback) – Erstelle einen Raum
spaceroom.joinRoom(roomId) – einem Raum beitreten
spaceroom.on(event, callback) – auf Ereignisse achten
…
Nachdem der Client eine Verbindung zum Server hergestellt hat, empfängt er verschiedene Ereignisse. Beispielsweise kann ein Benutzer in einem Raum ein Ereignis erhalten, dass ein neuer Spieler beigetreten ist oder dass das Spiel begonnen hat. Wir haben dem Kunden einen „Lebenszyklus“ gegeben und er wird sich jederzeit in einem der folgenden Zustände befinden:
Sie können den aktuellen Status des Clients über spaceroom.state abrufen.
Die Verwendung des serverseitigen Frameworks ist relativ einfach. Wenn Sie die Standardkonfigurationsdatei verwenden, können Sie das serverseitige Framework einfach direkt ausführen. Wir haben eine Grundvoraussetzung: Der Servercode kann direkt im Client ausgeführt werden, ohne dass ein separater Server erforderlich ist. Diejenigen unter Ihnen, die auf PS oder PSP gespielt haben, werden genau wissen, wovon ich spreche. Natürlich kann es auf einem dedizierten Server ausgeführt werden, was natürlich hervorragend ist.
Die Implementierung des Logikcodes wird hier vereinfacht. Die erste Generation von Spaceroom vervollständigte die Funktion eines Socket-Servers. Sie verwaltete eine Liste von Räumen, einschließlich des Status der Räume und der Spielzeitkommunikation (Befehlserfassung, Bucket-Broadcast usw.) für jeden Raum. Informationen zur spezifischen Implementierung finden Sie im Quellcode.
Synchronisationsalgorithmus
Wie können wir also dafür sorgen, dass die zwischen den einzelnen Clients angezeigten Dinge in Echtzeit konsistent sind?
Das Ding klingt interessant. Denken Sie sorgfältig darüber nach: Was brauchen wir, um den Server zu unterstützen? Es liegt nahe, darüber nachzudenken, was zu logischen Inkonsistenzen zwischen verschiedenen Clients führen kann: Benutzeranweisungen. Da die Codes, die die Spiellogik verarbeiten, unter denselben Bedingungen alle gleich sind, sind die Ergebnisse des Codes gleich. Der einzige Unterschied besteht in den verschiedenen Anweisungen, die der Spieler während des Spiels erhält. Natürlich brauchen wir eine Möglichkeit, diese Anweisungen zu synchronisieren. Wenn alle Clients die gleichen Anweisungen erhalten können, können alle Clients theoretisch die gleichen Laufergebnisse erzielen.
Die Synchronisationsalgorithmen von Online-Spielen sind sehr seltsam und auch ihre anwendbaren Szenarien sind unterschiedlich. Der von Spaceroom verwendete Synchronisationsalgorithmus ähnelt dem Konzept der Frame-Sperre. Wir unterteilen die Zeitachse in Intervalle und jedes Intervall wird als Bucket bezeichnet. Bucket wird zum Laden von Anweisungen verwendet und vom Server verwaltet. Am Ende jedes Bucket-Zeitraums sendet der Server den Bucket an alle Clients. Nachdem der Client den Bucket erhalten hat, ruft er Anweisungen daraus ab und führt sie nach der Überprüfung aus.
Um die Auswirkungen der Netzwerkverzögerung zu reduzieren, wird jede vom Server vom Client empfangene Anweisung gemäß einem bestimmten Algorithmus an den entsprechenden Bucket übermittelt. Befolgen Sie insbesondere die folgenden Schritte:
Angenommen, order_start ist die Zeit des Auftretens der Anweisung, die von der Anweisung übertragen wird, und t ist die Startzeit des Buckets, in dem sich order_start befindet
Wenn t Verzögerungszeit <= die Startzeit des Buckets, der gerade Anweisungen sammelt, wird die Anweisung an den Bucket übermittelt, der gerade Anweisungen sammelt, andernfalls fahren Sie mit Schritt 3 fort
Übermitteln Sie den Befehl an den Bucket, der t delay_time
entspricht
Darunter ist „delay_time“ die vereinbarte Serververzögerungszeit, die als durchschnittliche Verzögerung zwischen Clients angesehen werden kann. Der Standardwert in Spaceroom beträgt 80 und der Standardwert der Bucket-Länge beträgt 48. Am Ende jedes Bucket-Zeitraums beträgt der Der Server sendet diesen Bucket an alle Clients, die Anweisungen für den nächsten Bucket erhalten. Der Client führt automatisch eine Zeitanpassung in der Logik basierend auf dem empfangenen Bucket-Intervall durch, um den Zeitfehler innerhalb eines akzeptablen Bereichs zu kontrollieren.
Das bedeutet, dass der Client unter normalen Umständen alle 48 ms einen Bucket vom Server erhält. Wenn die Zeit für die Verarbeitung des Buckets erreicht ist, verarbeitet der Client ihn entsprechend. Unter der Annahme, dass der Client-FPS = 60 ist, wird etwa alle 3 Frames ein Bucket empfangen und die Logik wird basierend auf diesem Bucket aktualisiert. Wenn der Bucket nach Zeitüberschreitung aufgrund von Netzwerkschwankungen nicht empfangen wird, unterbricht der Client die Spiellogik und wartet. Innerhalb eines Buckets können logische Aktualisierungen die Lerp-Methode verwenden.
Im Fall von „delay_time = 80, Bucket_size = 48“ wird jede Anweisung um mindestens 96 ms verzögert. Wenn Sie diese beiden Parameter ändern, beispielsweise im Fall von „delay_time = 60, Bucket_size = 32“, wird jede Anweisung um mindestens 64 ms verzögert.
Ein durch einen Timer verursachter Mord
Im Großen und Ganzen muss unser Framework beim Ausführen über einen genauen Timer verfügen. Führen Sie eine Bucket-Übertragung in einem festen Intervall durch. Natürlich dachten wir zuerst darüber nach, setInterval() zu verwenden, aber in der nächsten Sekunde wurde uns klar, wie unzuverlässig diese Idee war: Das unartige setInterval() schien einen sehr schwerwiegenden Fehler zu haben. Und das Schreckliche ist, dass sich jeder Fehler anhäuft und immer schwerwiegendere Folgen hat.
Deshalb haben wir sofort darüber nachgedacht, setTimeout() zu verwenden, um die nächste Ankunftszeit dynamisch zu korrigieren, um unsere Logik um das angegebene Intervall herum ungefähr stabil zu halten. Diesmal ist setTimeout() beispielsweise 5 ms kürzer als erwartet, dann werden wir es beim nächsten Mal 5 ms früher machen. Die Testergebnisse sind jedoch nicht zufriedenstellend und das ist nicht elegant genug.
Wir müssen also noch einmal umdenken. Ist es möglich, setTimeout() so schnell wie möglich ablaufen zu lassen und dann zu prüfen, ob die aktuelle Zeit die Zielzeit erreicht? In unserer Schleife scheint es beispielsweise eine gute Idee zu sein, setTimeout(callback, 1) zu verwenden, um die Zeit ständig zu überprüfen.
Enttäuschender Timer
Wir haben sofort einen Code geschrieben, um unsere Idee zu testen, und die Ergebnisse waren enttäuschend. Führen Sie in der neuesten stabilen Version von node.js (v0.10.32) und der Windows-Plattform diesen Code aus:
Warten Sie, warum kommt Ihnen diese Nummer so bekannt vor? Entspricht die Zahl 15,625 ms zu sehr dem maximalen Timer-Intervall unter Windows? Ich habe sofort ClockRes zum Testen heruntergeladen und als ich es auf der Konsole ausgeführt habe, habe ich folgende Ergebnisse erhalten:
Wie erwartet! Wenn wir uns das Handbuch von node.js ansehen, können wir diese Beschreibung von setTimeout sehen:
Die tatsächliche Verzögerung hängt von externen Faktoren wie der Granularität des Betriebssystem-Timers und der Systemlast ab.
Die Testergebnisse zeigen jedoch, dass diese tatsächliche Verzögerung das maximale Timer-Intervall ist (beachten Sie, dass das aktuelle Timer-Intervall des Systems zu diesem Zeitpunkt nur 1,001 ms beträgt), was ohnehin inakzeptabel ist. Unsere große Neugier veranlasste uns, einen Blick auf den Quellcode zu werfen von node.js Schauen Sie genauer hin.
FEHLER in Node.js
Ich glaube, dass die meisten von Ihnen und ich ein gewisses Verständnis für den gleichmäßigen Schleifenmechanismus von Node.js haben. Wenn wir uns den Quellcode der Timer-Implementierung ansehen, können wir das Implementierungsprinzip des Timers grob verstehen Schleife der Ereignisschleife:
Die interne Implementierung dieser Funktion verwendet die Windows-Funktion GetTickCount(), um die aktuelle Uhrzeit festzulegen. Einfach ausgedrückt: Nach dem Aufruf der Funktion setTimeout und nach einer Reihe von Kämpfen wird der interne Timer->due auf das aktuelle Schleifen-Timeout gesetzt. Aktualisieren Sie in der Ereignisschleife zunächst die aktuelle Schleifenzeit über uv_update_time und prüfen Sie dann, ob in uv_process_timers ein Timer abgelaufen ist. Wenn ja, betreten Sie die Welt von JavaScript. Nachdem Sie den gesamten Artikel gelesen haben, hat die Ereignisschleife wahrscheinlich diesen Prozess:
Globale Zeit aktualisieren
Überprüfen Sie den Timer. Wenn ein Timer abläuft, führen Sie den Rückruf aus
Überprüfen Sie die Anforderungswarteschlange und führen Sie ausstehende Anforderungen aus
Geben Sie die Poll-Funktion ein, um E/A-Ereignisse zu sammeln. Wenn ein E/A-Ereignis eintrifft, fügen Sie die entsprechende Verarbeitungsfunktion zur Anforderungswarteschlange hinzu, damit sie in der nächsten Ereignisschleife ausgeführt wird. Innerhalb der Poll-Funktion wird eine Systemmethode aufgerufen, um IO-Ereignisse zu sammeln. Diese Methode blockiert den Prozess, bis ein IO-Ereignis eintrifft oder das festgelegte Timeout erreicht ist. Wenn diese Methode aufgerufen wird, wird das Timeout auf die letzte Timer-Ablaufzeit gesetzt. Dies bedeutet, dass die Sammlung von IO-Ereignissen blockiert ist und die maximale Blockierungszeit die Endzeit des nächsten Timers ist.
Quellcode einer der Umfragefunktionen unter Windows:
Wenn Sie die oben genannten Schritte befolgen und einen Timer mit einem Timeout = 1 ms einstellen, blockiert die Poll-Funktion für bis zu 1 ms und wird dann fortgesetzt (wenn in diesem Zeitraum keine E/A-Ereignisse vorliegen). Wenn Sie weiterhin in die Ereignisschleife eintreten, aktualisiert uv_update_time die Zeit. Anschließend stellt uv_process_timers fest, dass unser Timer abgelaufen ist, und führt einen Rückruf aus. Die vorläufige Analyse lautet also, dass entweder ein Problem mit uv_update_time vorliegt (die aktuelle Zeit wird nicht korrekt aktualisiert) oder die Poll-Funktion 1 ms wartet und dann wiederhergestellt wird, und mit dieser 1 ms-Wartezeit stimmt etwas nicht.
Bei der Suche nach MSDN haben wir überraschenderweise eine Beschreibung der GetTickCount-Funktion gefunden:
Die Auflösung der GetTickCount-Funktion ist auf die Auflösung des Systemtimers beschränkt, die typischerweise im Bereich von 10 Millisekunden bis 16 Millisekunden liegt.
Die Genauigkeit von GetTickCount ist so grob! Gehen Sie davon aus, dass die Poll-Funktion 1 ms lang korrekt blockiert, aber bei der nächsten Ausführung von uv_update_time wird die aktuelle Schleifenzeit nicht korrekt aktualisiert! Es wurde also nicht davon ausgegangen, dass unser Timer abgelaufen war, also wartete Poll eine weitere ms und trat in die nächste Ereignisschleife ein. Erst als GetTickCount endlich korrekt aktualisiert wurde (sogenannte Aktualisierung alle 15,625 ms) und die aktuelle Zeit der Schleife aktualisiert wurde, wurde festgestellt, dass unser Timer in uv_process_timers abgelaufen war.
Bitten Sie WebKit um Hilfe
Dieser Quellcode von Node.js ist sehr hilflos: Er verwendet eine Zeitfunktion mit geringer Genauigkeit ohne jegliche Verarbeitung. Aber wir dachten sofort, dass wir, da wir Node-WebKit verwenden, zusätzlich zu setTimeout von Node.js auch setTimeout von Chromium haben. Schreiben Sie einen Testcode und führen Sie ihn mit unserem Browser oder Node-WebKit aus: http://marks.lrednight.com/test.html#1 (Die Zahl nach # gibt das Intervall an, das gemessen werden muss) , das Ergebnis ist wie folgt:
Gemäß den HTML5-Spezifikationen sollte das theoretische Ergebnis 1 ms für die ersten 5 Mal und 4 ms für die nachfolgenden Ergebnisse betragen. Die im Testfall angezeigten Ergebnisse beginnen ab dem dritten Mal, was bedeutet, dass die Daten in der Tabelle bei den ersten drei Malen theoretisch 1 ms betragen sollten und die nachfolgenden Ergebnisse alle 4 ms betragen. Die Ergebnisse weisen einen gewissen Fehler auf, und gemäß den Vorschriften beträgt das kleinste theoretische Ergebnis, das wir erhalten können, 4 ms. Obwohl wir nicht zufrieden sind, ist dies offensichtlich viel zufriedenstellender als das Ergebnis von node.js. Leistungsstarker Curiosity-Trend Werfen wir einen Blick auf den Quellcode von Chromium, um zu sehen, wie er implementiert ist. (https://chromium.googlesource.com/chromium/src.git/ /38.0.2125.101/base/time/time_win.cc)
Um die aktuelle Zeit der Schleife zu ermitteln, verwendet Chromium zunächst die Funktion timeGetTime(). Wenn Sie MSDN konsultieren, können Sie feststellen, dass die Genauigkeit dieser Funktion vom aktuellen Timer-Intervall des Systems beeinflusst wird. Auf unserer Testmaschine sind es theoretisch die oben genannten 1.001ms. In Windows-Systemen ist das Timer-Intervall jedoch standardmäßig der Maximalwert (15,625 ms auf dem Testcomputer), es sei denn, die Anwendung ändert das globale Timer-Intervall.
Wenn Sie Nachrichten in der IT-Branche verfolgen, müssen Sie eine solche Neuigkeit gesehen haben. Es scheint, dass unser Chromium das Timer-Intervall sehr klein eingestellt hat! Es scheint, dass wir uns über die System-Timer-Intervalle keine Gedanken mehr machen müssen? Seien Sie nicht zu früh glücklich, dieser Fix versetzt uns einen Schlag. Tatsächlich wurde dieses Problem in Chrome 38 behoben. Müssen wir eine Reparatur des vorherigen Node-WebKit verwenden? Das ist offensichtlich unelegant und hindert uns daran, leistungsfähigere Versionen von Chromium zu verwenden.
Wenn wir uns den Chromium-Quellcode genauer ansehen, können wir feststellen, dass Chromium das globale Timer-Intervall des Systems ändert, um einen Timer mit einer Genauigkeit von weniger als 15,625 ms zu erreichen, wenn ein Timer vorhanden ist und der Timer-Timeout
其中, kMinTimerIntervalLowResMs = 4, kMinTimerIntervalHighResMs = 1.timeBeginPeriod und timeEndPeriod für Windows Das Timer-Intervall beträgt etwa 1 ms Die Laufzeit beträgt 4 ms. Die Laufzeit beträgt 4 ms气,这个对我们的影响不大.
又一个精度问题
Eine Zeitspanne von 4 ms, eine Zeitspanne von 4 ms und eine Zeitspanne von 4 ms 🎜>http://marks.lrednight.com/test.html#48 Die Laufzeit beträgt 48 ms und 49 ms. Die Event-Schleife von Chromium und Node.js ist IO个 Windows 函数调用的精度,受当前系统的计时器影响。游戏逻辑的实现需要用到 requestAnimationFrame为 kMinTimerIntervalLowResMs(因为他需要一个个16ms的计时器,触发了高精度计时器的要求)。测试机Das Zeitintervall beträgt etwa 1 ms, die Zeitspanne beträgt ± 1 ms时器间隔,运行上面那个#48的测试,maximale Länge von 48 16=64ms.
Chromium verwendet setTimeout, um setTimeout(fn, 1) für 4 ms und setTimeout(fn, 48) zu verwenden在 1 ms 左右.于是,我们的心中有了一幅新的蓝图,它让我们的代码看起来像是这样:
Der obige Code ermöglicht es uns, auf eine Zeit zu warten, in der der Fehler kleiner als Bucket_size (Bucket_Size – Abweichung) ist, anstatt direkt einem Bucket_Size zu entsprechen. Selbst wenn der maximale Fehler gemäß der obigen Theorie mit einer Verzögerung von 46 ms auftritt. Das tatsächliche Intervall beträgt weniger als 48 ms. Den Rest der Zeit nutzen wir die Busy-Waiting-Methode, um sicherzustellen, dass unser gameLoop in einem ausreichend präzisen Intervall ausgeführt wird.
Obwohl wir das Problem bis zu einem gewissen Grad mit Chromium gelöst haben, ist es offensichtlich nicht elegant genug.
Erinnern Sie sich an unsere ursprüngliche Anfrage? Unser serverseitiger Code sollte unabhängig vom Node-Webkit-Client und direkt auf einem Computer mit einer Node.js-Umgebung laufen können. Wenn Sie den obigen Code direkt ausführen, beträgt der Abweichungswert mindestens 16 ms, was bedeutet, dass wir alle 48 ms 16 ms warten müssen. Die CPU-Auslastung steigt ständig.
Unerwartete Überraschung
Es ist wirklich ärgerlich. Es gibt so einen großen Fehler in Node.js, und niemand hat ihn bemerkt? Die Antwort hat uns wirklich überrascht. Dieser Fehler wurde in Version 0.11.3 behoben. Sie können die geänderten Ergebnisse auch sehen, indem Sie direkt den Master-Zweig des libuv-Codes anzeigen. Die spezifische Methode besteht darin, der aktuellen Zeit der Schleife eine Zeitüberschreitung hinzuzufügen, nachdem die Poll-Funktion auf den Abschluss gewartet hat. Selbst wenn GetTickCount nach dem Warten auf die Abfrage nicht antwortet, fügen wir auf diese Weise diese Wartezeit hinzu. So kann der Timer reibungslos ablaufen.
Mit anderen Worten: Das Problem, dessen Bearbeitung lange gedauert hat, wurde in Version 0.11.3 gelöst. Unsere Bemühungen sind jedoch nicht umsonst. Denn selbst wenn der Einfluss der GetTickCount-Funktion eliminiert wird, wird auch die Poll-Funktion selbst vom System-Timer beeinflusst. Eine Lösung besteht darin, ein Node.js-Plug-in zu schreiben, um das Intervall des Systemtimers zu ändern.
Die Grundeinstellung für unser Spiel ist dieses Mal jedoch, dass es keinen Server gibt. Nachdem der Client einen Raum erstellt hat, wird dieser zum Server. Der Servercode kann in der Node-WebKit-Umgebung ausgeführt werden, sodass das Timer-Problem unter Windows-Systemen nicht die höchste Priorität hat. Gemäß der oben angegebenen Lösung reicht das Ergebnis aus, um uns zufriedenzustellen.
Ende
Nach der Lösung des Timer-Problems gibt es grundsätzlich keine Hindernisse für die Implementierung unseres Frameworks. Wir bieten WebSocket-Unterstützung (in einer reinen HTML5-Umgebung) und passen auch das Kommunikationsprotokoll an, um eine leistungsstärkere Socket-Unterstützung zu implementieren (in einer Node-WebKit-Umgebung). Natürlich waren die Funktionen von Spaceroom zu Beginn noch relativ rudimentär, aber mit steigenden Anforderungen und zunehmender Zeit haben wir das Framework nach und nach verbessert.
Als wir beispielsweise feststellten, dass wir in unserem Spiel konsistente Zufallszahlen generieren mussten, fügten wir Spaceroom eine solche Funktion hinzu. Spaceroom verteilt Zufallszahlen-Seeds, wenn das Spiel startet. Der Spaceroom des Kunden bietet eine Methode, die Zufälligkeit von md5 zu nutzen, um mithilfe von Zufallszahlen-Seeds Zufallszahlen zu generieren.
Sieht sehr zufrieden aus. Beim Schreiben eines solchen Frameworks habe ich auch viel gelernt. Wenn Sie sich für Spaceroom interessieren, können Sie auch daran teilnehmen. Ich glaube, dass Spaceroom seine Stärke an mehr Orten zeigen wird.