Workshop 2: Simple 3D Chat


1.0 Einführung/Vorraussetzungen

Willkommen lieber ANet Benutzer in diesem zweiten Workshop. Gemeinsam erstellen wir eine einfache 3D Chat-Applikation in der man sich mit einem zuvor ausgewählten Charakter bewegen kann und mit anderen Mitspielern chatten kann. Außerdem werden Themen wie korrektes Synchronisieren der Clients, Levelwechsel, Kicken von Clients,… behandelt. Grundvorraussetzungen für dieses Tutorial sind Lite-C Kenntnisse, eine ANet Version (Demo, Standard oder Professional) und eine Vollversion von 3D Gamestudio A7/A8.
Falls Sie bei einer Plugin Funktion nicht wissen, was sie bewirkt bzw. wie sie eingesetzt werden soll, lesen Sie bitte im Plugin Manual nach. (Tipp: Eine Funktion lässt sich am leichtesten mit der Suchfunktion des Manuals finden!)

2.0 Beginn

Zu Beginn laden Sie sich bitte das fertige Projekt von der Downloadseite von der ANet Homepage herunter und starten Sie die beiliegende 3dchat.exe (im "lite-c" Ordner). Ein Fenster mit der Auswahlmöglichkeit Server, Client, Server/Client wird geöffnet. Wählen Sie nun Server/Client aus. Danach geben Sie einen Spielernamen ein und drücken [Enter]. Wählen Sie nun Ihren Charakter aus. Nun sollten Sie folgendes Fenster vor sich haben (abhängig von dem ausgewählten Charakter):



Starten Sie nun die .exe ein 2. Mal (ACHTUNG: Schließen Sie das 1. Fenster nicht!) und wählen Sie Client aus. Für die IP-Adresse geben Sie "localhost" ein und bestätigen mit [Enter]. Danach können Sie wieder einen Spielernamen eingeben und einen Charakter auswählen. Nachdem Sie Ihren Charakter ausgewählt haben, sollte sich der Client über den Localhost mit dem Server verbunden haben. Sie können nun mit den Tasten [w] [a] [s] [d] Ihren Charakter durch das Level bewegen und per Mausbewegung die Kamera verändern. Mit der Taste [C] können Sie eine Chatnachricht eingeben:



Die Chatnachricht wird auf allen verbundnen Clients+Server unter dem Spielernamen angezeigt. (Der Spielername "schwebt" über dem Kopf Ihres Charakters. Der Spielername/die Chatnachricht wird nicht auf dem Client der die Entity erzeugt hat angezeigt.)

Okay, so weit so gut. Aktivieren Sie nun das Fenster in dem Sie den Server/Client erstellt haben und drücken Sie [L]. Danach wird automatisch auf allen Clients und auf dem Server das Level gewechselt. Durch Drücken von [1] können Sie den Client mit der ClientID 1 kicken (in unserem Fall das 2. Fenster). Durch Drücken der Taste [Q] können Sie das Fenster schließen.

3.0 Programmierung

Da Sie nun wissen wie die Applikation funktionieren soll, beginnen wir sie zu programmieren. Um den Umfang des Tutorials nicht zu sprengen, wird hier nicht der gesamte Sourcecode erklärt, sondern nur die wichtigsten Funktionen. (Die Funktionen die hier nicht erklärt werden, sind in dem Code dokumentiert und können dort eingesehen werden!)

3.1 Starten




Zuerst muss enet_init() ausgeführt werden. Diese Funktion initialisiert die ENet Library. Wird diese Funktion nicht ausgeführt, wird keine Funktion die ENet benutzt funktionieren. Danach werden die System- bzw. Userevents gesetzt.

Ein Event in ANet funktioniert gleich wie ein Event bei Entities im Gamestudio: Bei einem gewissen Geschehnis, wird die angegebene Funktion ausgeführt und entsprechende Parameter mit Informationen über den Vorfall übergeben. So wird zum Beispiel das EVENT_CONNECTED am Server ausgeführt, sobald sich ein Client verbunden hat. Außerdem wird die ClientID des Clients per Parameter sender übergeben.

Achtung: Jede ANet Eventfunktion muss als Parameterangabe "var sender, char* msg, var length" enthalten.

Per enet_svset_event() bzw. enet_clset_event() wird nun eine Funktion einem Event am Server bzw. am Client zugewiesen. Ab Event Nummer 16 beginnen die Userevents. Diese Events werden nicht durch ein internes Ereignis wie das Verbinden eines Clients oder durch einen Levelwechsel ausgelöst, sondern durch Senden eines Events per enet_svsend_event() oder enet_clsend_event().

Okay, nachdem alle Funktionen den Events per enet_sv/clset_event() zugewiesen wurden, ist die Initialisierungsphase abgeschlossen. Erwähnenswert ist an dieser Stelle noch das Warnungssystem von ANet. Dieses warnt Sie vor falschen Aufrufen von ANet Funktionen. Per enet_set_warning() kann dieses ein- und ausgeschaltet werden. In der Entwicklungsversion ist es besser, wenn Sie die Warnungen eingeschaltet lassen. In der Releaseversion sollten sie jedoch deaktiviert werden.

Per enet_deinit() kann die Initialisierung von der ENet Library wieder rückgängig gemacht werden. Diese Funktion muss nicht zwingend verwendet werden, da beim Beenden der Engine der benützte Speicher automatisch wieder freigegeben wird. Sollten Sie die ENet Funktionen jedoch nur kurz benötigen, so können Sie durch enet_deinit() den verbrauchten Arbeitsspeicher entleeren und so Speicher sparen.

3.2 Aufbau einer Verbindung




Es gibt zwei Unterprogramme. Eines, das einen Server initialisiert und verwaltet und eines, das einen Client initialisiert und verwaltet. In start_server() wird zuerst das Level geladen, da das Level beim Aufruf von enet_set_level() bereits geladen sein muss. Danach wird 3 Frames gewartet bis das Level fertig geladen wurde.

Danach wird per enet_init_server() ein Host mit der Funktion eines Servers gestartet. Als Host wird im Prinzip jeder Rechner in einem Netzwerk bezeichnet. Der Server wird unsere Daten, die von einem Client gesendet werden, weiterleiten. Außerdem ist er für das Verwalten der Playernamen und der globalen Entities zuständig. Per enet_set_level() wird nun der Dateiname des aktuell geladenen Levels gespeichert.

start_client() ähnelt start_server() . Der einzige Unterschied besteht darin, dass statt einem Server ein Client initialisiert und noch kein Level geladen wird.

Um nun einen Server zu initialisieren, muss einfach start_server() ausgeführt werden. Für einen Client start_client() und wenn man im Client-Server Modus starten möchte, müssen einfach beide Funktionen nacheinander ausgeführt werden.
Aber Achtung: Die Reihenfolge ist entscheidend! Zuerst muss der Server und erst dann der Client initialisiert werden. Außerdem sollten Sie zwischen dem Ausführen von start_server() und start_client() 4 Frames warten, damit das Level sicher geladen ist und der Server initialisiert wurde.

Per enet_get_connection() können Sie herausfinden, welche Art von Host initialisiert wurde. Server, Client oder beides. Aber Achtung: Dies heißt lediglich, welcher Host initialisiert wurde und nicht, ob eine Verbindung besteht! Wenn Sie herausfinden wollen ob sich Ihr Client mit dem Server Verbunden hat, können Sie einerseits enet_get_clientid() verwenden, denn sobald erfolgreich eine Verbindung aufgebaut wurde, ist der Rückgabewert ungleich ANET_ERROR. Serverseitig können Sie die Verbindung zu einem Client per enet_check_client() überprüfen. Anderseits können sie die Systemevents EVENT_CONNECTED und EVENT_DISCONNECTED verwenden. Diese werden serverseitig bzw. clientseitig aufgerufen, wenn sich ein Client erfolgreich verbunden hat bzw. die Verbindung unterbrochen wurde.

Im Normalfall benötigt man beide Methoden. Die Eventmethode wird verwendet, sobald man irgendetwas beim Verbinden/Trennen eines Clients ausführen möchte (z.B. Anzeigen einer Nachricht, Entfernen einer Entity,…). Die andere Methode wird normalerweise in Schleifenbedingungen benutzt (z.B. wenn sich ein Spieler so lange bewegen soll, solange eine Verbindung zum Server besteht):
while(enet_get_clientid() != ANET_ERROR)

3.3 Verbindungs-Systemevents und Synchronisieren




Die Verbindungs-Systemevents sind jene Events, die aufgerufen werden, wenn sich etwas an der Verbindung zu einem Client ändert. Es gibt das EVENT_CONNECTED und das EVENT_DISCONNECTED. Wird das EVENT_CONNECTED am Server aufgerufen, so hat sich ein Client mit dem Server verbunden. Wird das EVENT_DISCONNECTED am Server aufgerufen, so hat der Client die Verbindung wieder unterbrochen. Am Client wird EVENT_CONNECTED und EVENT_DISCONNECTED nur aufgerufen, wenn sich der Client selbst mit dem Server verbunden bzw. die Verbindung unterbrochen hat. Verbindet sich ein anderer Client mit dem Server, so werden diese Events nicht auf den anderen Clients aufgerufen, sondern nur auf dem, den es betrifft.

Der Parameter sender enthält jeweils die ClientID des entsprechenden Clients. Dadurch kann wie im obigen Beispiel in client_disconnected() ganz einfach die Spielerentity des Clients entfernt werden.

Der Parameter msg enthält beim EVENT_CONNECTED am Client den Namen der Leveldatei des Levels, das am Server momentan geladen ist (wird am Server durch enet_set_level() angegeben).

Sehen wir uns nun an, wie richtiges Synchronisieren eines Clients funktioniert. Dazu benötigen wir das EVENT_SYNCHRONIZED. Es wird jeweils am Server und Client aufgerufen, sobald das Synchronisieren des Clients erfolgreich war.

Okay, nehmen wir an, dass bereits ein Server initialisiert wurde. Nun möchten wir einen Client mit dem Server verbinden lassen. Darum initialisieren wir auf einem anderen PC bzw. auf dem Selben, nur in einem anderen Fenster, einen Client. Im Hintergrund versucht sich nun der Client mit dem Server zu verbinden.

Sobald die Verbindung erfolgreich hergestellt werden konnte, wird die Funktion connected_with_server() aufgerufen (EVENT_CONNECTED). In der Funktion connected_with_server() wird nun als erstes der Spielername per enet_set_playername() angegeben. Danach wird überprüft, ob als Client gestartet wurde und nicht als Client/Server. Nun wird das selbe Level geladen, das am Server geladen wurde. Der Name der .wmb Datei des Levels wird in dem Parameter msg übergeben. Nun müssen 3 Frames gewartet werden bis das Level fertig geladen wurde.

Die Abfrage war deshalb notwendig, da beim Starten im Client/Server Mode das Level bereits geladen wurde. Erinnern Sie sich? Das Level wird beim Aufruf von start_server() geladen! Außerdem ist eine Synchronisierung unnötig, da sowieso noch keine Entities erstellt worden sein können.

Okay, nun wird enet_ent_synchronize() aufgerufen. Dieses lässt den Server alle Entitydaten zum Client senden. Sobald die Synchronisierung erfolgreich abgeschlossen wurde, wird synchronizing_complete() aufgerufen (EVENT_SYNCHRONIZED). Ab dem Zeitpunkt des Aufrufes dieser Funktion, können Sie alle Entityfunktionen verwenden.
Aber Achtung: Bevor die Synchronisierung nicht erfolgreich abgeschlossen wurde, sollten Sie keine Entityfunktionen verwenden, außer ein Synchronisieren wäre unnötig, wenn z.B. noch keine Entities erstellt wurden.

3.4 Erstellen eines Charakters




Nachdem unser Client erfolgreich synchronisiert wurde, wird durch den Aufruf des Unterprogramms create_player() ein Spieler erstellt. Dazu wird genau der Character verwendet, der zuvor im "Select Character" Menü ausgewählt wurde.

Zuerst müssen wir dafür sorgen, dass die Spieler nicht ineinander erstellt werden. Darum hat jeder der 4 möglichen Clients eine eigene Startposition für seinen Charakter. Danach wird abgefragt, welcher Charakter ausgewählt wurde (gespeichert in der Variable character) und erstellt dann per enet_ent_create() die entsprechende Entity auf allen Rechnern.

Die Funktion move_player() wird auf allen Rechnern ausgeführt. Achtung: Vergessen sie hier nicht, dass sie nicht den Funktionspointer bei enet_ent_create() übergeben müssen, sondern den Namen der Funktion!

3.5 Bewegen eines Charakters




Zuerst muss solange gewartet werden, bis der Entity ein globaler Pointer zugewiesen wurde! Dies ist äußerst wichtig, da der globale Pointer nicht sofort nach der Erstellung der Entity verfügbar ist. Danach wird der Pointer der Entity lokal in dem Array players gespeichert. Der Index gibt die ClientID des Erstellers an. Dadurch kann auf jede Entity zugegriffen werden, auch wenn man nur die ClientID des Erstellers hat.

Als nächstes wird die Funktion durch eine Abfrage in 2 Teile aufgeteilt: Der erste Teil läuft nur auf dem Client, der die Entity erstellt hat und der zweite Teil auf allen anderen Rechnern.

Besprechen wir den ersten Teil: Die Entity kann per Mausbewegung entlang der x-Achse rotiert werden und per [w] [a] [s] [d] bewegt werden. Es wirkt Schwerkraft auf die Entity. Wenn einer der 4 Tasten gedrückt ist, wird die Geh-Animation abgespielt. Sobald die Gehanimation abgespielt werden soll, wird skill[42] auf 1 gesetzt. Sobald dies geschieht wird skill[42] per enet_send_skills() an alle Rechner gesendet damit diese auch die Gehanimation abspielen.

Wenn sich die Position der Entity verändert hat, wird die neue Entityposition per enet_send_pos() an alle gesendet. Wenn sich die Winkel der Entity verändert haben, werden per enet_send_angle() die neuen Winkel an alle gesendet. Durch DONTSEND_Z bzw. ONLYSEND_PAN kann das Senden der z-Koordinate bzw. das Senden von Roll und Tilt verhindert werden. Dies spart Traffic. Die Camera bleibt immer hinter dem Spieler und der camera.tilt kann per Mausbewegung entlang der y-Achse verändert werden.

Diese Art der Positionsaktualisierung funktioniert zwar für den Anfang nicht schlecht, wird aber so in der Praxis nicht verwendet. Das Problem dieser Methode ist, dass sehr viel Traffic verbraucht wird. Außerdem werden bei hohen Latenzzeiten ruckartige Bewegungen bei den anderen Clients sichtbar (bei denen, die die Entity nicht erstellt haben).
Dieses Problem kann durch Glätten der Bewegung gut ausgebessert werden. Um eine geglättete Bewegung zu erreichen, wird einfach zwischen der aktuellen Position und der gesendeten interpoliert. Achtung: enet_send_pos() kann hier nicht verwendet werden, da diese Funktion die Entity sofort auf die gesendete Position setzt. Am besten sie verwenden hier 3 Skills für die gesendete Position.

Die Glättungsmethode löst zwar das Problem der ruckartigen Bewegungen, aber nicht das Trafficproblem. Darum ist die gängigste Methode Dead-Reckoning. Der Grundgedanke ist der, dass nicht die neue Position gesendet wird, sondern nur ob und wie sich die Entity bewegt.

Man nimmt zum Beispiel einen Skill, der 1 wird, wenn sich die Entity vorwärts bewegt, 2 wenn sie sich rückwärts bewegt, etc. und sendet diesen, wenn er sich verändert hat. Auf den anderen Clients wird nun die entsprechende Bewegung ausgeführt.
Man erkennt leicht, dass hier nur das Senden von einem Skill nötig ist und das auch viel seltener, als das Senden von einem Positionsvektor mit 3 Elementen (x,y,z). Dadurch wird natürlich viel weniger Traffic verbraucht.
Ein Problem ist jedoch das Überschießen = Wenn die Entity sich durch hohe Latenzzeiten auf den anderen Clients übers Ziel hinausbewegt, obwohl sie auf dem Client, der sie erstellt hat, bereits steht. Um nun Überschießen zu verhindern, sollte bei markanten Bewegungsänderungen (z.B. Kollision mit einer Mauer) ein Positionsupdate per enet_send_pos() stattfinden.
Dies war nur ein grober Überblick über Dead-Reckoning. Es gibt jedoch sehr gute Tutorials im Internet zu diesem Thema! Außerdem ist in den ANet Versionen Standard/Professional ein Dead-Reckoning Template verfügbar, das Ihnen die Arbeit abnimmt. Ein HowTo zu dem Template finden Sie im Manual.

Der 2. Teil unserer player_move() Funktion: Zuerst wird ein TEXT Objekt erstellt. Dieses zeigt den Spielernamen des Erzeugers der Entity an und seine Chatnachrichten. Das handle des Textobjekts wird in skill[40] der Entity gespeichert, um so später außerhalb der player_move() Funktion darauf zugreifen zu können.

In der while Schleife wird nun das TEXT Objekt immer über dem Kopf der Entity platziert. Die Distanz zwischen Entitykopf und TEXT Objekt wird mit der Distanz Camera-Entity reduziert. Als nächstes wird der Spielername des Erstellers der Entity ermittelt. Dazu wird enet_get_playername() und enet_ent_creatorid() verwendet. Abschließend wird die Entity animiert, wenn skill[42] gleich 1 ist.

3.6 Senden/Darstellen einer Chatnachricht





Ein Chatsystem kann ganz einfach per enet_sv/clsend_event() realisiert werden. Dazu definieren wir ein Userevent. Ich habe Event Nummer 17 verwendet. Sie können aber auch irgendein anderes verwenden. Es muss nur zwischen 16 und 250 sein und darf noch nicht belegt sein! Die Eventfunktion heißt receive_chatmsg() und wird ausgeführt, sobald eine Chatnachricht empfangen wurde.

Sehen wir uns nun die chat_startup() Funktion an. Diese wird (wie eine starter Funktion in C-Script) beim Starten der Engine automatisch ausgeführt. Zuerst wird darauf gewartet, dass ein Server bzw. Client initialisiert wird. Danach wird in einer Endlosschleife auf das Drücken der Taste [C] gewartet. Wird die Taste gedrückt, so startet die Eingabe der Chatnachricht. Nachdem die Eingabe beendet wurde wird der eingegebene String per enet_send_event() gesendet. Als Eventtyp muss hier Event Nummer 17 genommen werden bzw. das, das Sie benützt haben. Dadurch wird das Userevent Nummer 17, also die Funktion receive_chatmsg(), ausgeführt und im msg Parameter wird die Chatnachricht übergeben.

In receive_chatmsg() wird nun die Chatnachricht in den 2. String des TEXT Objekts der Entity kopiert. Dadurch wird die Chatnachricht über dem Kopf der Entity sichtbar. Danach wird 3 Sekunden gewartet und wenn inzwischen keine neue Chatnachricht gesendet wurde, wird der String geleert => es ist keine Nachricht mehr sichtbar.

3.7 Kicken eines Clients





Auch das Kicken eines Clients ist mit ANet sehr einfach zu realisieren. In der Funktion kick_startup() wird zuerst wieder so lange gewartet, bis ein Host initialisiert wird. Wurde ein Client initialisiert, wird die Funktion automatisch beendet. Ansonsten wartet sie auf einen Tastendruck ([0], [1], [2] oder [3]) und trennt dann durch enet_disconnect_client() die Verbindung zum entsprechenden Client. Der String der per enet_disconnect_client() gesendet wird, wird beim EVENT_DISCONNECTED dem Client übergeben. Dadurch kann der Server dem Client senden, warum er die Verbindung getrennt hat.

3.8 Levelwechsel





Um einen Levelwechsel auf allen Rechnern durchzuführen, muss am Server einfach nur das neue Level geladen werden und enet_set_level() ausgeführt werden. Danach wird am Client das entsprechende EVENT_LEVEL ausgeführt. Im Parameter msg wird der Name des zu ladenden Levels übergeben. Also muss im Event nur noch das entsprechende Level geladen werden und fertig ist der Levelwechsel.

In unserem Beispiel wird zusätzlich noch das players Array geleert, da es sonst ungültige Pointer beinhalten würde. Außerdem werden die TEXT Objekte der einzelnen Spieler gelöscht.

3.9 Verlassen der Applikation





Am besten ist, wenn man beim Schließen der Applikation dem Server noch ein Event sendet, damit dieser sofort die Verbindung zum Client trennt. Dadurch muss nicht auf das lange Timeout gewartet werden. Dadurch wird die Spielerentity und alles andere des Clients sofort gelöscht bzw. rückgesetzt (siehe client_disconnected()). Nach dem Aufruf von enet_clsend_event() muss noch einen Frame gewartet werden bis das Paket versendet wurde und danach kann die Engine heruntergefahren werden. Es muss nicht gewartet werden, bis der Server die Verbindung getrennt hat

4.0 Abschließende Worte

Ich hoffe, dass ich Ihnen alle wichtigen Funktionen und Abläufe von ANet näher bringen konnte. Wenn Sie große Multiplayerspiele schreiben wollen, ist es wichtig, dass sie mit allen Einzelheiten Ihres Netzwerktools vertraut sind. Deshalb sollten Sie, bevor Sie mit einem großen Multiplayerspiel starten, alle Funktionen selbst einmal ausprobiert und ihre Anwendung geübt haben. Wenn Sie die internen Abläufe der Funktionen verstanden haben, sollten ihnen das Fehlersuchen in ihrem Code einfacher fallen. Außerdem werden sie mit der Zeit ein Gefühl dafür bekommen, wann welche Funktion am besten wie eingesetzt wird.
Sehen sie sich alle verfügbaren Multiplayerbeispiele hin und wieder an, um die Tipps und Tricks daraus zu verstehen und später auch selbst anwenden zu können. Lesen sie andere Multiplayertutorials und beschäftigen Sie sich mit Netzwerktechnik. Dann wird Ihrem eigenen Multiplayerspiel nur noch ihre eigene Fantasie im Wege stehen können!

Viel Spaß und viel Erfolg bei ihren Multiplayerprojekten!