somethinglikegames.de

Mein Blog über Spieleentwicklung und das Drumherum


Kategorien


Tags

Der Quellcode zu den Artikeln ist in diesem GitHub Repository zu finden. Die folgende Liste zeigt alle bislang erschienenen Artikel dieser Serie:

  1. Netzwerktutorial 1: Allgemeines und Übersicht
  2. Netzwerktutorial 2: Das "gehende Skelett"
  3. Netzwerktutorial 3: Login 1 - Der Game-Client
  4. Netzwerktutorial 4: Login 2 - Gateway- und Authentication-Server
  5. Netzwerktutorial 5: Login 3 - World-Server
  6. Netzwerktutorial 6: Verschlüsselte Verbindungen mit Hilfe von DTLS

Mit Hilfe des letzten Artikels haben wir nun ein Grundgerüst, bestehend aus drei Servern, die auf den UDP-Ports 1909-1911 laufen und uns per Konsolenausgabe über Verbindungsaufbau/-abbau von Clients informieren. Dieser Artikel beschäftigt sich mit dem ersten Teil des Loginvorgangs und wir erstellen den Game-Client soweit, dass er eine Verbindung zum Gateway-Server aufbaut und die Logindaten dorthin verschickt. Aber zuvor besprechen wir noch etwas allgemeiner den Loginvorgang und RPCs bei Godot.

Loginvorgang

In der klassischen synchronen Softwareentwicklung würde die geplante Loginsequenz wie in dem nachfolgenden Sequenzdiagramm aussehen. Auch wenn synchrone Programmierung oftmals bequemer für den Entwickler ist, erzielt sie gerade bei rechenintensiven oder latenzbehafteten Aufrufen, also immer wenn ein Netzwerk involviert ist, für den Nutzer schlechte Ergebnisse. Dazu kommt, dass die auf ENet basierende Netzwerkschicht in Godot auf asynchrone Kommunikation ausgelegt ist. Denn es werden auch Broadcasts unterstützt, bei denen der Server bei allen aktuell verbundenen Clients einen Funktionsaufruf, einen sogenannten Remote Procedure Call (RPC), auslösen kann. Wie lange soll dann ein solcher Aufruf dauern? Bis alle, also bis zu 4k, Clients ihr Resultat zurückgeliefert haben? Zumal man zusätzlich bedenken muss, dass bei den Godot-RPCs auch konfiguriert werden kann, dass solche Aufrufe unzuverlässig zugestellt werden können, es also keine Garantie gibt, dass ein Client den Aufruf bekommen hat, oder nicht.

Synchrone Loginsequenz

Synchrone Loginsequenz

Aus diesem Grund nehmen wir uns den synchronen Ablauf zum Vorbild und bilden ihn asynchron nach. Das bedeutet, dass die jeweiligen RPCs, wie im Sequenzdiagramm eingezeichnet, stattfinden. Die eingezeichnete “Wartezeit” wiederum kann nun anderweitig genutzt werden. Die GUI des Game-Clients kann z.B. weiterhin auf Nutzereingaben reagieren oder auch eine Fortschrittsanzeige anzeigen und aktualisieren. Der Gateway-Server kann im selben Thread auf weitere Login-Requests reagieren. Entscheidend ist, dass die eingezeichneten Antworten nun auch RPCs sind und der Gateway-Server in der Lage sein muss, die Antwort an den ursprünglichen Aufrufer weiterzuleiten.

Remote Procedure Call (RPC)

Bei RPC-basierter Kommunikation handelt es sich, neben der nachrichtenbasierten Kommunikation, um ein grundlegendes Modell der Netzwerkprogrammierung. Das Schöne dabei ist, dass es für den Entwickler ähnlich wie ein lokaler Funktionsaufruf ist. Bei der von uns verwendeten Netzwerkschicht von Godot ENet sind alle RPC-Aufrufe asynchrone Aufrufe, also generell schon mal Funktionen mit Rückgabewert void. Wenn man aber nach synchroner Denkweise einen Rückgabewert haben möchte bzw. muss, um weiter “arbeiten” zu können, muss man ebenfalls eine Funktion zur Verfügung stellen, einen sogenannten Callback, die der Kommunikationspartner dann mit dem Ergebnis (synchron: Rückgabewert) per RPC aufruft.

Zwischen Godot 3.x und Godot 4 hat sich einiges bei der Syntax im Netzwerkbereich getan. Ich werde hier zwar nur noch auf die neue Syntax eingehen, aber wer sich die alte Syntax anschauen möchte, wird sicherlich in der alten Dokumentation dazu fündig. Die erste und größte Neuerung ist wohl die neue Annotation @rpc für alle Funktionen, die via RPC aufgerufen können werden soll.

@rpc("call_remote", "any_peer", "reliable")
func s_login_request(_username: String, _password: String) -> void:
	pass

Es sind vier Parameter für @rpc vorgesehen:

mode
Legt fest, wer die Funktion aufrufen darf. Mögliche Werte sind "authority" (Standard) und "any_peer"
sync
Bestimmt, ob die Funktion ausschließlich remote aufgerufen wird oder zusätzlich ebenfalls lokal. Mögliche Werte sind "call_remote" (Standard) und "call_local"
transfer_mode
Es gibt insgesamt drei mögliche Modi. unreliable (Standard) bedeutet, dass die Funktionsaufrufe ohne weitere Garantien abgesendet werden. Mit unreliable_ordered kann festgelegt werden, dass immer nur die zuletzt abgeschickte Nachricht ausgewertet wird, wenn also zuerst A und dann B abgesendet werden und B vor A beim Empfänger ankommt, wird ausschließlich B ausgewertet und A verworfen. Kommt A vor B an, so wird natürlich zuerst A ausgewertet und anschließend B. Bei reliable werden Mechanismen etabliert, um die Ankunft der Nachricht/des Funktionsaufrufs zu garantieren (ein wenig wie bei TCP), was zur Folge hat, dass nach jedem reliable-Versand der Empfänger den Emfang quittieren muss, bevor die nächste reliable-Nachricht auf diesem Kanal verschickt werden kann.
channel
Bestimmt, auf welchem Kanal eine Nachricht/ein Funktionsaufruf stattfindet. Dabei sind alle Kanäle Teile der gleichen Netzwerkverbindung. Der Standardwert ist 0, ansonsten sind, soweit ich das momentan absehen kann, alle anderen int Werte zugelassen. Durch unterschiedliche Kanäle kann man bspw. Latenzen, die durch reliable Aufrufe entstehen, verringern, indem man unterschiedliche reliable Aufrufe über unterschiedliche Kanäle laufen lässt. Wie zuvor erklärt, wird ansonsten bei jedem reliable Funktionsaufruf auf die Quittierung des Empfängers gewartet, bevor der nächste reliable Funktionsaufruf auf dem Kanal verschickt werden kann. Ein weiterer essentieller Einsatzzweck für unterschiedliche Kanäle entsteht bei der Nutzung vom transfer_mode unreliable_ordered, da sich die Sortierung auf alle Netzwerknachrichten des Kanals bezieht und nicht nur auf gleiche Funktionsaufrufe. Dies hat zur Folge, dass bei 3 abgeschickten unterschiedlichen RPCs mit unreliable_ordered über den selben Kanal, abhängig von der Empfangsreihenfolge, 1 bis maximal 3 davon ausgeführt und alle nicht ausgeführten verworfen werden, auch wenn jeder Aufruf eine andere Funktion aufrufen sollte.

Die Reihenfolge der ersten drei Parameter ist, wie im Beispiel gezeigt, egal, aber die Angabe des Kanals muss immer als viertes Argument erfolgen.

Mehr Informationen und kleinere Beispiele zu diesem Thema finden sich auch im zugehörigen Blog-Post dieser Syntax-Änderung.

Eine zweite Syntax-Änderung, die mit Godot 4 kommt, ist dass jedes Callable nun die Funktionen rpc() bzw. rpc_id() besitzt. Dadurch lässt sich der RPC direkt auf dem Callable aufrufen und man benötigt eigentlich keine weitere allgemeingültige Funktion mehr, der der Funktionsname per String übergeben werden muss. Dadurch fallen Umbenennungen von Funktionen schon direkt zur Compile-Zeit auf und nicht erst zur Laufzeit. Der Unterschied zwischen rpc() und rpc_id() ist, dass bei rpc_id() noch die Netzwerk-ID des Empfängers angegeben wird. Die neue Syntax im Einsatz sieht dann z.B. wie folgt aus:

# Neue Syntax:
s_login_request.rpc_id(MultiplayerPeer.TARGET_PEER_SERVER, username, password)

# Alternative/ bisherige Syntax:
rpc_id(MultiplayerPeer.TARGET_PEER_SERVER, "s_login_request", username, password)

Game-Client

Login-GUI (./scenes/gui/login_screen.tscn)

SceneTree der Login-GUI

SceneTree der Login-GUI.

Nach einem langen inneren Kampf, ob ich wirklich eine Login-GUI bauen soll, fangen wir nun mit genau einer solchen GUI an. Vorab: Meine Kenntnisse bzgl. GUI-Design, vor allem mit Godot, sind ziemlich beschränkt. Mir geht es bei dieser GUI auch nicht um das schönste oder beste Design, sondern um eine möglichst sinnvolle Funktionalität für dieses Tutorial. Daher kann man in der GUI auch Netzwerk-Adresse und -Port für Gateway- und World-Server einstellen. Wie man am SceneTree erkennen kann, habe ich versucht, möglichst viel Funktionalität in möglichst wenig Design unterzubringen. Ich hatte nur zwei Kriterien an das Design: Erstens sollen die GUI-Elemente unabhängig von der Fenstergröße zentriert werden. Zweitens sollen Label und Eingabefelder (LineEdit) ordentlich untereinander stehen. Mit Hilfe von CenterContainer lässt sich die Anforderung des Zentrierens sehr einfach umsetzen, da dieser Container alle seine Control Kinder automatisch zentriert. Mein zweites Kriterium habe ich mit einem zweispaltigen GridContainer gelöst. Damit das wie gewünscht funktioniert, müssen die Kindelemente immer zwischen Label und Eingabefeld abwechseln. Damit dies auch für die Eingabefelder der Server-Einträge gelingt, habe ich die jeweiligen Textfelder (LineEdit) für Netzwerk-Adresse und -Port in einem HBoxContainer zusammengefasst, so passt es dann auch mit der alternierenden Reihenfolge. Um mir den Tippaufwand zu sparen, sind die Textfelder für Gateway- und World-Server schon passend auf 127.0.0.1 und 1910 bzw. 1909 vorbelegt. Die GUI sieht dann wie folgt aus:

Login-GUI

Login-GUI

Passend dazu ist auch das zugehörige GDScript sehr einfach gehalten. Es besitzt nur zwei Funktionen:

  1. _on_login_pressed(), die mit dem Signal pressed von login_btn verknüpft ist
  2. reset(), die die GUI wieder zurücksetzen soll
 1extends Control
 2
 3@onready var gw_server_addr_txt := $Center/Grid/GatewayServer/Server
 4@onready var gw_server_port_txt := $Center/Grid/GatewayServer/Port
 5@onready var world_server_addr_txt := $Center/Grid/WorldServer/Server
 6@onready var world_server_port_txt := $Center/Grid/WorldServer/Port
 7@onready var username_txt := $Center/Grid/Username
 8@onready var password_txt := $Center/Grid/Password
 9@onready var login_btn := $Center/Grid/Login
10
11var login : Callable
12
13
14func reset() -> void:
15	login_btn.disabled = false
16
17
18func _on_login_pressed() -> void:
19	login_btn.disabled = true
20	login.call(NetworkConnectionData.new(gw_server_addr_txt.text,
21			gw_server_port_txt.text as int),
22			NetworkConnectionData.new(world_server_addr_txt.text,
23			world_server_port_txt.text as int),
24			username_txt.text, password_txt.text)
Es sollte auffallen, dass ich auf jegliche Eingabevalidierung verzichtet habe, was man keinesfalls außerhalb einer Testanwendung machen darf. Ohne Validierung erhöht man nicht nur die Wahrscheinlichkeit für Fehleingaben seitens der Nutzer und reduziert dadurch die UX, sondern man vereinfacht auch Angriffe auf das eigene Backend, da man bspw. 1 GB Text über jegliche Eingabefelder an das Backend schicken kann und dieser auf diese Datenmengen wahrscheinlich nicht ausgelegt ist.

Die ganze “Magie” dieses Skripts findet innerhalb von _on_login_pressed() statt. Dort wird zuerst login_btn deaktiviert (Zeile 19), um ein Mehrfachdrücken zu verhindern. Anschließend wird ein Callable namens login mit den Inhalten der Eingabefelder aufgerufen. Was sich genau hinter login verbirgt, sehen wir uns gleich an. Zuerst habe ich noch eine neue Klasse namens NetworkConnectionData in ./scripts/classes/network_connection_data.gd hinzugefügt. Wie man im nachfolgenden Quellcode sehen kann, handelt es sich dabei lediglich um einen einfachen Datencontainer, der Netzwerk-Adresse und -Port bündeln soll und noch eine _to_string() Funktion besitzt, um bessere Konsolenausgaben zu erzeugen:

 1class_name NetworkConnectionData
 2
 3var address: String
 4var port: int
 5
 6func _init(_address: String, _port: int) -> void:
 7	address = _address
 8	port = _port
 9
10func _to_string() -> String:
11	return "%s:%d" % [address, port]

SceneHandler (./scenes/main.tscn)

Wie im vorigen Artikel bereits kurz erwähnt, möchte ich die Startszene (./scenes/main.tscn) des Game-Clients als SceneHandler konzipieren. Das heißt, der gesamte Kontrollfluss des Game-Clients wird über diese Szene und das zugehörige GDScript gesteuert. Aus diesem Grund ist dort auch der notwendige Glue-Code zu finden, um die Verdrahtung von Signalen, Callbacks, Abhängigkeiten etc. durchzuführen:

 1extends Node
 2
 3var login_screen := preload("res://scenes/gui/login_screen.tscn")
 4
 5var gateway_server : Node
 6var world_server_data : NetworkConnectionData
 7
 8
 9func _ready() -> void:
10	_load_login_screen()
11
12
13func _load_login_screen() -> void:
14	for c in get_children():
15		c.queue_free()
16	var instance = login_screen.instantiate()
17	instance.login = _login
18	add_child(instance)

Beim Start des Game-Clients wird in _ready() die Funktion _load_login_screen() (Zeile 10) aufgerufen. Die Aufgabe dieser Funktion ist es, die zuvor vorgestellte Szene der Login-GUI zu initialisieren und zu laden. Bevor dies aber geschieht, wird “aufgeräumt”. Dafür werden in Zeile 14 alle Kinder der Szene durchlaufen, um sie für die Löschung vorzumerken. Anschließend wird der vorgeladene login_screen (Zeile 3) instanziiert, der Callback login wird gesetzt und dann wird die Login-GUI dem aktuellen SceneTree hinzugefügt (Zeile 18).

21func _login(gw_server: NetworkConnectionData, wrld_server: NetworkConnectionData,
22			username: String, password: String) -> void:
23	world_server_data = wrld_server
24	gateway_server = Node.new()
25	gateway_server.name = "GatewayServer"
26	add_child(gateway_server)
27	gateway_server.set_script(load("res://scenes/network/gateway_server.gd"))
28	get_tree().set_multiplayer(SceneMultiplayer.new(), ^"/root/Main/GatewayServer")
29	gateway_server.gateway_server = gw_server
30	gateway_server.callback = _login_callback
31	gateway_server.login_to_server(username, password)

Wie wir ja bereits im Abschnitt zuvor gesehen haben, wird der übergebene Callback _login() (Zeile 21-22) mit den Daten aller Eingabefelder der Login-GUI aufgerufen . Die Netzwerkkoordinaten des World-Servers werden in einer lokalen Variable für die spätere Verwendung gespeichert, während alle anderen Informationen direkt weiterverarbeitet werden. Dafür wird ab Zeile 24 ein Node für den Gateway-Server erzeugt, benannt, dem SceneTree hinzugefügt und initialisiert. Am Ende wird noch die Funktion login_to_server() des Gateway-Servers aufgerufen, wodurch dann die Netzwerkverbindung zum Gateway-Server aufgebaut wird, was wir uns im nächsten Abschnitt näher ansehen werden.

34func _login_callback(return_code: int, token: String) -> void:
35	remove_child(gateway_server)
36	if return_code == OK:
37		print("Login was successful")
38		print("Current Token is: %s" % token)
39		# Connect to World Server
40	else :
41		print("Something went wrong…")
42		get_node("LoginScreen").reset()

Die Funktion _login_callback() (ab Zeile 34) beherbergt den Callback für den Gateway-Server. Dort wird der Node des Gateway-Servers wieder gelöscht, da er nun nicht mehr benötigt wird. Anschließend wird aktuell im Erfolgsfall eine kleine Nachricht und der erhaltene Token als Konsolenmeldung ausgegeben. Später soll hier dann, mit Hilfe des Tokens, der Kontakt zum World-Server hergestellt werden, aber soweit sind wir noch nicht. Im Fehlerfall wird eine kleine Debug-Nachricht ausgegeben und anschließend die Funktion reset() der Login-GUI aufgerufen.

Gateway-Server-Client (./scenes/network/gateway_server.gd)

Die Aufgabe des Gateway-Server-Clients ist es, unschwer zu erraten, die Kommunikation mit dem Gateway-Server zu übernehmen. Zum “Leben” erweckt wird er, wie wir bereits besprochen haben, im SceneHandler, wo, nach der Instanziierung und Initialisierung, die Funktion login_to_server() aufgerufen wird.

 1extends Node
 2
 3var network := ENetMultiplayerPeer.new()
 4var gateway_server : NetworkConnectionData
 5var username : String
 6var password : String
 7var callback : Callable
 8
 9func login_to_server(_username: String, _password: String) -> void:
10	username = _username
11	password = _password
12
13	print("Connecting to %s" % gateway_server.to_string())
14	var ret = network.create_client(gateway_server.address, gateway_server.port)
15	if ret == OK:
16		get_multiplayer().multiplayer_peer = network
17		get_multiplayer().connected_to_server.connect(_on_connection_succeeded)
18		get_multiplayer().connection_failed.connect(_on_connection_failed.bind(FAILED))
19	else:
20		_on_connection_failed(ret)

Um Kontakt mit dem Gateway-Server aufzunehmen wird in Zeile 14 zunächst ein ENet-Client erzeugt. Wenn dies erfolgreich war, werden, ähnlich wie im letzten Tutorial, der ENetMultiplayerPeer im Multiplayer-Objekt hinterlegt und die Signale connected_to_server und connection_failed verknüpft. Im Fehlerfall wird direkt die Funktion _on_connection_failed() aufgerufen, wo dann die Fehlerbehandlung getriggert wird.

23func _on_connection_failed(ret: int) -> void:
24	print("Failed to connect to gateway server on %s, errorcode was %d"
25			% [gateway_server.to_string(), ret])
26	callback.call(ret, "")

Die Fehlerbehandlung in _on_connection_failed() ist sehr minimalistisch gehalten. Es wird eine kurze Konsolenmeldung ausgegeben und anschließend der Callback, den der SceneHandler hinterlegt hatte, aufgerufen. Diese Funktion wird nicht nur aufgerufen, wenn die Erzeugung des Netzwerk-Clients misslingt, sondern auch, wenn das Signal connection_failed getriggert wird.

29func _on_connection_succeeded() -> void:
30	print("Succesfully connected to gateway server %s" % [gateway_server.to_string()])
31	print("send login request to gateway server")
32	s_login_request.rpc_id(MultiplayerPeer.TARGET_PEER_SERVER, username, password)
33	username = ""
34	password = ""
35
36
37@rpc("call_remote", "any_peer", "reliable")
38func s_login_request(_username: String, _password: String) -> void:
39	pass # on gateway server

Wenn _on_connection_succeeded() nach erfolgreichem Verbindungsaufbau zum Gateway-Server durch connected_to_server aufgerufen wird, findet dort der eigentliche Loginvorgang statt. Dafür wird in Zeile 32 die Funktion s_request_login als RPC auf dem Server aufgerufen. Als Übergabeparameter steht bei der Funktion rpc_id() an erster Stelle immer die numerische ID des entfernten Systems. Der Server hat immer die ID 1, welche auch als Konstante MultiplayerPeer.TARGET_PEER_SERVER verfügbar ist. Alle weiteren Parameter werden an die aufgerufene Funktion übergeben.

Wie man ab Zeile 37 sehen kann, ist die Funktion s_login_request() auch im Client vorhanden, auch wenn sie ausschließlich auf dem Server aufgerufen wird. Neben identischem Netzwerkpfad und SceneTree müssen auf Server und Client auch alle Funktionen, die per RPC verfügbar sind, vorhanden sein. Dabei müssen alle @rpc-Parameter, der Funktionsname und auch die Übergabeparameter (Anzahl und Typ) übereinstimmen, während die Implementierungen hingegen unterschiedlich sein können und sollten. Dem Server kann egal sein, was der Client in seinen Funktionen macht und umgekehrt gilt es erst recht. Man bezeichnet solche funktionslosen Implementierungen auch gerne als Stubs. Um dennoch gut zwischen Server- und Client-Funktionen unterscheiden zu können beginnen bei mir aktuell alle Server-Funktionen mit s_ und alle Client-Funktionen mit c_. Zusätzlich versuche ich, zusammengehörige Kommunikationsverläufe durch gleichnamige Funktionen, die dann entweder auf _request oder _response enden, sichtbar zu machen.

42@rpc("call_remote", "authority", "reliable")
43func c_login_response(result: bool, token: String) -> void:
44	print("login response is %s" % str(result))
45	print("token is '%s'" % token)
46	get_multiplayer().connected_to_server.disconnect(_on_connection_succeeded)
47	multiplayer.set_multiplayer_peer(null)
48	network = null
49	callback.call(OK if result else ERR_INVALID_DATA, token)

Die Funktion c_login_result() (Zeile 43) wird vom Gateway-Server als Antwort auf s_login_request() aufgerufen. Nach der Ausgabe von Debug-Konsolenmeldungen wird die Verbindung zum Server getrennt und der Callback mit dem Ergebnis aufgerufen. Ab da verläuft es dann, wie zuvor im SceneHandler gezeigt, weiter. Im Fehlerfall wird der Login-Button wieder freigeschaltet und im Erfolgsfall gibt es weitere Konsolenausgaben, da ich die Kommunikation mit dem World-Server für ein späteres Tutorial geplant habe.

Der nächste Schritt

Im nächsten Artikel wird es um den nächsten Schritt gehen: Der Gateway-Server nimmt den Login-Request des Game-Clients entgegen und leitet diesen an den Authentication-Server weiter, welcher die Logindaten verifiziert und im Erfolgsfall ein Token erstellt. Das Resultat wird dann über den Gateway-Server zurück an den Game-Client gesendet.