somethinglikegames.de

Mein Blog über Spieleentwicklung und das Drumherum


Kategorien


Tags

Bei diesem Artikel möchte ich mich vorab schon entschuldigen, dass die Positionen von Text und Bildern nicht immer ganz stimmig sind. Leider ist mir keine bessere Positionierung eingefallen, aber ich hoffe, ihr könnt es verkraften. Im heutigen Tutorial soll es nun nach der Tutorialserie, die ein gutes Grundgerüst bietet, mal um ein Thema gehen, was man auch wirklich sehen kann. Das hier gezeigte Beispiel basiert dabei auf einem Blog-Artikel von Fabio Alessandrelli, einem der Hauptentwickler für die Godot-Netzwerkfunktionalität. Und da ich nicht alles nur kopieren möchte, erfahrt ihr hier, wie ihr die dort gezeigten Techniken auch bei Nutzung von pfadabhängigen MultiplayerAPIs einsetzen könnt.

Um diesem Tutorial folgen zu können, braucht ihr nicht unbedingt das Projekt aus meiner Tutorialserie, aber es schadet auch nicht 😉. Die bisherigen Godot-Projekte findet ihr im zugehörigen Github-Repository. Alternativ reicht es aber aus, ein Godot-Projekt zu haben, welches bereits eine Netzwerkverbindung zwischen Client und Server herstellen kann.

Wir wollen uns heute mit Scene Replication beschäftigen. Mit diesem Begriff beschreibt man die Aufgabe, die aktuelle Spielszenerie, also bspw. Level, Spielerpositionen, etc., an alle verbundenen GameClients zu senden. So wird versucht, allen GameClients ein (möglichst) identisches Bild über das aktuelle Spielgeschehen zu vermitteln. Es gibt, wie so oft, verschiedene Ansätze dafür. Um das Cheating-Potential möglichst gering zu halten, sollte man bei Netzwerkspielen immer auf autoritäre Server setzen. Dies bedeutet, dass der GameClient “dumm” ist und lediglich Eingaben registriert, diese an den Server sendet und ansonsten die vom Server übermittelte Spielszenerie anzeigt. Alle Berechnungen bzgl. Positionsänderungen, Aktionen und deren Auswirkungen etc. werden vom Server getätigt.

In der heutigen Zeit ist dies nicht mehr ganz zutreffend, da man heutzutage versucht, die Latenz, die durch die Netzwerkkommunikation notgedrungen entsteht, zu kompensieren, indem man bestimmte Berechnungen zusätzlich vom GameClient ausführen lässt. Dabei werden aber weiterhin die Berechnungen des GameClients durch die jeweiligen Resultate des Servers überschrieben, sobald die Netzwerkpakete ankommen. Das Thema Lag-Compensation ist sehr komplex und wird vielleicht in einem späteren Artikel näher beleuchtet.

Godot 4 bietet zwei neue High-Level-Komponenten, die uns ermöglichen, Scene Replication durchzuführen, ohne uns um die darunterliegende RPC-Kommunikation kümmern zu müssen:

MultiplayerSynchronizer
Synchronisiert angegebene Properties (z.B. Positionsdaten) zwischen den Netzwerkteilnehmern. Der Standardwert für den Synchronisierungsintervall ist variabel und entspricht den aktuellen Spiel-FPS. Möchte man also eine feste Tickrate wie in vielen First Person Shootern (bspw. Counter-Strike: Global Offensive) haben, muss man dies über “Replication Interval” auf jedem MultiplayerSynchronizer oder zentral in den Projekteinstellungen konfigurieren.
MultiplayerSpawner
Repliziert spawnbare Nodes von der Netzwerkautorität, in unserem Fall dem Server, an die anderen Netzwerkteilnehmer, in unserem Fall also alle Clients.

Als Beispiel werden im Laufe des Artikels der GameClient und der WorldServer so erweitert, dass für jeden verbundenen GameClient eine Spielfigur erscheint und vom zugehörigen GameClient gesteuert werden kann. Um das Beispiel so einfach wie möglich zu halten, basiert die Steuerung auf dem “Basic Movement”-Template von CharacterBody3D, sodass sich die Figuren nach links, rechts, vor und zurück bewegen und außerdem springen können.

Bevor wir nun aber wirklich beginnen, soll das folgende Ergebnisvideo noch etwas motivieren:

Die Player-Szene

Aufbau der Player-Szene

Aufbau der Player-Szene

Die Player-Szene ist, wie man im zugehörigen Screenshot sehen kann, eigentlich ganz simpel aufgebaut. Unser oberster Knoten ist ein CharacterBody3D, wie es wahrscheinlich bei jedem 3D Godot-Projekt für die Spielfigur der Fall ist. Dementsprechend gibt es als Kinder, wie gefühlt in jedem zweiten Beispiel, ein CollisionShape3D mit CapsuleShape3D als Shape und eine MeshInstance3D mit CapsuleMesh als Mesh. Es werden fast alle Standardeinstellungen belassen, nur als position, unterhalb von Transform, verwenden wir (0, 1, 0) als Vektor, damit nichts im Boden hängt. Zusätzlich gibt es noch eine Camera3D, die als Spielerkamera dient. Die genauen Werte der Kamera sind für das Tutorial nicht so wichtig, man sollte nur neben der Spielfigur noch etwas von der Umgebung sehen können. Die Player-Szene existiert sowohl im GameClient als auch im WorldServer, genauso wie die jeweiligen Skripte.

Konfiguration des PlayerInputs

Konfiguration des PlayerInputs

Kommen wir nun zu den beiden neuen Knoten “ServerSynchronizer” und “PlayerInput”. Dabei handelt es sich um Knoten vom Typ MultiplayerSynchronizer, und sie nehmen uns das Synchronisieren von Properties ab. Es lässt sich für jeden MultiplayerSynchronizer einzeln bestimmen, ob man bspw. einen abweichenden Synchronisierungsintervall benutzen möchte.

Der PlayerInput-Knoten

Die Aufgabe von “PlayerInput” ist es, die Eingaben des jeweiligen Spielers im GameClient abzufangen und an den Server zu schicken. Das kann man auch schon an den abgebildeten Properties von PlayerInput.gd erahnen, die wir uns dann gleich im Skript noch näher ansehen werden. Als RootPath ist PlayerInput ausgewählt, da der GameClient nur innerhalb (und unterhalb) dieses Knotens die Erlaubnis besitzen soll, Eingabedaten zu verschicken, da die Ergebnisse ja vom Server berechnet werden sollen.

Wie man im Screenshot des zugehörigen Replication-Tabs erkennen kann, wird ein Property namens direction via Sync synchronisiert. Dieses Property beinhaltet den Eingabevektor des Spielers bzw. GameClients und soll vom Server genutzt werden, um die Geschwindigkeit und die Position zu berechnen.

Replication-Tab des PlayerInputs

Replication-Tab des PlayerInputs

Generell hat man mit Spawn, Sync und Watch drei mögliche Einstellungen, um die Synchronisierung zu beeinflussen. Spawn synchronisiert das Property direkt beim Spawnen, also während des Einfügens des Knotens in die Szene. Sync synchronisiert das Property im konfigurierten Intervall. Watch synchronisiert das Property zuverlässig im konfigurierten Intervall, sofern sich das Property verändert hat.

Watch ist noch ganz frisch und erst mit Godot 4.1 dazugekommen. Von der Beschreibung klingt es nach der besseren Alternative zu Sync, da nur dann synchronisiert wird, wenn sich auch etwas geändert hat. Ich habe es mit Sync gemacht, da ich einerseits das Beispiel für diesen Artikel vor Godot 4.1 fertig und getestet hatte und mich andererseits am Blog-Artikel von Fabio Alessandrelli orientieren wollte.

Kommen wir nun zum zugehörigen Skript player_input.gd. Wie vorher bereits erwähnt, werden dort alle möglichen Spielereingaben ausgelesen und dann an den Server geschickt. Für die vorgesehenen Eingabemöglichkeiten reichen die zwei Variablen jumping und direction aus. jumping zeigt dabei an, ob gesprungen werden soll, und direction als Vector2 beinhaltet die Bewegungsrichtung des Spielers.

In Zeile 8 innerhalb der _ready()-Funktion wird bestimmt, ob der “PlayerInput”-Knoten aktiv sein soll oder nicht. Selbstverständlich soll der Knoten nur dann aktiv sein, wenn es sich um den “PlayerInput”-Knoten des aktiven GameClients handelt, da man ansonsten alle anderen Spielfiguren ebenfalls zeitgleich steuern würde. Innerhalb der _process()-Funktion bestimmen wir die Eingaben für direction, dafür bedienen wir uns der Funktion Input.get_vector(), um so direkt den passenden Vector2-Wert zu bekommen. Die Sprungeingabe wird realisiert, indem wir die jump()-Funktion als RPC aufrufen, was dank @rpc("call_local") nicht nur auf dem Server, sondern auch lokal ausgeführt wird.

 1extends MultiplayerSynchronizer
 2
 3@export var jumping := false
 4@export var direction := Vector2()
 5
 6
 7func _ready() -> void:
 8	set_process(get_multiplayer_authority() == multiplayer.get_unique_id())
 9
10
11func _process(delta: float) -> void:
12	direction = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
13	if Input.is_action_just_pressed("ui_accept"):
14		jump.rpc()
15
16
17@rpc("call_local")
18func jump():
19	jumping = true

Das Player-Skript

Nachdem wir uns angesehen haben, wie die Eingabedaten gesammelt und an den Server gesendet werden, schauen wir uns deren Verarbeitung im Skript player.gd an, welches an unserem Hauptknoten Player hängt. Die Property-Deklaration dürfte ziemlich gewöhnlich aussehen, außer dem Property player bzw. dem zugehörigen Setter. Dieser Wert wird vom Server bei der Erstellung gesetzt, und im Setter setzen wir dann die übergebene ID als MultiplayerAuthority für unseren PlayerInput-Knoten. Dadurch können wir, wie im vorigen Abschnitt gezeigt, in der _ready()-Funktion des PlayerInput-Knotens die Überprüfung machen, ob das Skript ausgeführt werden soll oder nicht.

 1extends CharacterBody3D
 2
 3
 4const SPEED = 5.0
 5const JUMP_VELOCITY = 4.5
 6
 7# Get the gravity from the project settings to be synced with RigidBody nodes.
 8var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
 9
10# Set by server (authority), synchronized on spawn
11@export var player := 1:
12	set(id):
13		player = id
14		$PlayerInput.set_multiplayer_authority(id)
15
16@onready var input = $PlayerInput

In der _ready()-Funktion prüfen wir, ob der GameClient für die Spielfigur zuständig ist. Sollte er dafür zuständig sein, setzen wir Camera3D als aktuelle Kamera.

Innerhalb von _physics_process() findet dann, wie gewohnt, die Verarbeitung der Eingabedaten statt, nur dass diese nun nicht erst ermittelt werden müssen, sondern dank des MultiplayerSynchronizers in input.direction bereits vorbereitet sind.

19func _ready() -> void:
20	if player == multiplayer.get_unique_id():
21		$Camera3D.current = true
22
23
24func _physics_process(delta: float) -> void:
25	# Add the gravity.
26	if not is_on_floor():
27		velocity.y -= gravity * delta
28
29	# Handle Jump.
30	if input.jumping and is_on_floor():
31		velocity.y = JUMP_VELOCITY
32	input.jumping = false
33
34	var direction := (transform.basis * Vector3(input.direction.x, 0, input.direction.y)).normalized()
35	if direction:
36		velocity.x = direction.x * SPEED
37		velocity.z = direction.z * SPEED
38	else:
39		velocity.x = move_toward(velocity.x, 0, SPEED)
40		velocity.z = move_toward(velocity.z, 0, SPEED)
41
42	move_and_slide()

Der ServerSynchronizer-Knoten

Konfiguration des ServerSynchronizers

Konfiguration des ServerSynchronizers

Nachdem nun im Player-Skript die eigentliche Bewegung der Spielfigur stattgefunden hat, müssen die relevanten Ergebnisdaten vom Server an alle verbundenen GameClients repliziert werden. Das machen wir mit Hilfe des “ServerSynchronizers”. Da dieser Synchronizer direkt mit den Properties von unserem Player-Knoten arbeitet, ist der Player-Knoten auch als Root Path hinterlegt. Alle anderen Optionen bleiben auf ihren Standardwerten. Wie man im nachfolgenden Screenshot des zugehörigen Replication-Tabs sehen kann, werden drei Werte mit Hilfe dieses Knotens übermittelt: player, position und velocity.

Hinter player verbirgt sich die einzigartige Multiplayer-ID des GameClients, die vom Server vergeben wird, sobald ein neuer Player gespawnt wird. Bei position und velocity handelt es sich um die bekannten Vector3-Werte aus Node3D bzw. CharacterBody3D, welche die aktuelle Position bzw. die aktuelle Geschwindigkeit beinhalten. Sowohl die Position als auch die Geschwindigkeit sollen nicht nur bei der Knotenerstellung (während des Spawns) synchronisiert werden, sondern auch regelmäßig zur Laufzeit.

Replication-Tab des ServerSynchronizers

Replication-Tab des ServerSynchronizers

Damit ist die Besprechung der Player-Szene soweit abgeschlossen. Unser Spiel ist nun in der Lage, zur Laufzeit Eingabedaten auf den GameClients einzusammeln, diese auf dem Server zu verarbeiten und die relevanten Ergebnisdaten dann wieder an die GameClients zu schicken. Das Problem ist nur, wie kommen denn die jeweiligen Instanzen der Player-Szene in das Spiel?

Die Level-Szene

Aufbau der Level-Szene

Aufbau der Level-Szene

Im nebenstehenden Screenshot sieht man den Aufbau meiner Level-Szene. Sie ist möglichst einfach gehalten, damit wir uns auf das Wesentliche konzentrieren können. Daher gibt es einzig einen Boden (Floor), der ein PlaneMesh mit 25mx25m als MeshInstance3D als Kind hat. Das andere Kind CollisionShape3D ist ein WorldBoundaryShape3D, sodass kein GameClient irgendwo herunterfallen kann. Das OmniLight3D sorgt für etwas Licht, auf die genauen Werte kommt es für dieses Beispiel nicht an. Bei Objects und Players handelt es sich um leere Container, wo dann Objekte bzw. Spielfiguren hineingespawnt werden sollen. Zu guter Letzt kommt noch der PlayerSpawner. Dabei handelt es sich um die eingangs erwähnte zweite High-Level-Komponente MultiplayerSpawner für die Scene Replication. Dieser PlayerSpawner ist dafür zuständig, dass alle GameClients es mitbekommen, wenn eine neue Spielfigur gespawnt wird. Um dies zu erreichen, muss der PlayerSpawner zuerst richtig konfiguriert werden.

Konfiguration des PlayerSpawners

Konfiguration des PlayerSpawners

Wie wir im nebenstehenden Screenshot des Inspectors vom PlayerSpawner-Knoten sehen können, gibt es zwei Properties, die wir anpassen müssen, um das gewünschte Ergebnis zu erreichen. Zuerst muss der Spawn Path auf den Players-Knoten gesetzt werden. Diese Konfiguration hat zur Folge, dass nun der Players-Knoten überwacht wird. Sobald eine Instanz von einem Element der Auto Spawn List als direktes Kind des Players-Knotens erscheint, wird dies an alle GameClients kommuniziert, sodass die dortigen PlayerSpawner-Knoten ebenfalls einen passend instanziierten Kindknoten erstellen können. Damit unser Spawner nun also Instanzen der Player-Szene replizieren kann, muss diese Szene in die Auto Spawn List aufgenommen werden.

Der Aufbau der Level-Szene ist auf dem GameClient und dem WorldServer (fast) identisch. Der WorldServer hat zusätzlich noch ein aktives Skript, welches das Hinzufügen und Entfernen der Spielfiguren kontrolliert, wenn sich GameClients verbinden bzw. trennen. Dafür werden in der zugehörigen _ready()-Funktion die Funktionen add_player() und del_player() mit den zugehörigen Signalen verbunden. In _exit_tree() räumen wir natürlich 😉 auf und beenden das Belauschen der Signale.

 1extends Node3D
 2
 3const SPAWN_RANDOM := 5.0
 4
 5@onready var players := $Players
 6
 7
 8func _ready() -> void:
 9	for id in multiplayer.get_peers():
10		add_player(id)
11	get_multiplayer().peer_connected.connect(add_player)
12	get_multiplayer().peer_disconnected.connect(del_player)
13
14func _exit_tree():
15	get_multiplayer().peer_connected.disconnect(add_player)
16	get_multiplayer().peer_disconnected.disconnect(del_player)

Die Funktion add_player() hat die Aufgabe, die Player-Szene passend zu instanziieren und dann dem Players-Knoten als direktes Kind hinzuzufügen. Dafür instanziieren wir zuerst natürlich die eigentlich Player-Szene und hinterlegen die übergebene Netzwerk-ID des zugehörigen GameClients im player-Property. Anschließend schaffen wir mit etwas Zufallsmagie eine Startposition, benennen den Knoten nach der Netzwerk-ID und fügen ihn zum Players-Knoten hinzu.

20func add_player(id: int) -> void:
21	var character := preload("res://scenes/levels/player.tscn").instantiate()
22	character.player = id
23	# Randomize character position
24	var pos := Vector2.from_angle(randf() * 2 * PI)
25	character.position = Vector3(pos.x * SPAWN_RANDOM * randf(), 0, pos.y * SPAWN_RANDOM * randf())
26	character.name = str(id)
27	players.add_child(character, true)

Sollte ein GameClient die Verbindung beenden, wird die Funktion del_player() aufgerufen. Dort wird geprüft, ob es einen Knoten unterhalb des Players-Knotens mit dem Namen der übergebenen Netzwerk-ID gibt. Falls dem so ist, wird dieser gelöscht.

30func del_player(id: int):
31	if not players.has_node(str(id)):
32		return
33	players.get_node(str(id)).queue_free()

Dank richtig konfiguriertem PlayerSpawner werden die hinzugefügten bzw. entfernten Player-Szenen-Knoten unterhalb des Players-Knotens automatisch an alle GameClients repliziert. So können sich nun GameClients dynamisch verbinden bzw. trennen, und alle anderen GameClients bekommen dies mit. Allen, die ein eigenes Beispielprojekt genommen haben, gratuliere ich an dieser Stelle 👏, da nun alles Wesentliche gezeigt wurde. Allen anderen gratuliere ich ebenfalls 👏, aber freue mich zusätzlich darüber, dass wir nun alles noch in das Beispielprojekt aus der Tutorialserie integrieren dürfen.

Integration in das Beispielprojekt aus der Tutorialserie

Nochmal zur Rekapitulation: Das Beispielprojekt aus der Tutorialserie besteht aus insgesamt 4 Komponenten - AuthenticationServer, GatewayServer, WorldServer und GameClient. Authentication- und GatewayServer sind ausschließlich während des Logins involviert, welcher bereits abgeschlossen ist, wenn eine Spielfigur erstellt und mit einem GameClient verknüpft werden soll. Daher brauchen wir dort keine Anpassungen vornehmen und können sie, wie auch bisher in diesem Artikel, weiter ignorieren.

Integration in den WorldServer

Aufbau der Main-Szene

Aufbau der Main-Szene

Kümmern wir uns zuerst um die Main-Szene des WorldServers. Dort bekommt der WorldServer-Knoten zwei Kinder. Einmal Level als Elternknoten des zu ladenden Levels. Und dann noch einen Knoten namens LevelSpawner, der vom Typ MultiplayerSpawner ist und das geladene Level replizieren soll. Daher wählen wir als Spawn Path den Geschwisterknoten Level aus, setzen ein Spawn Limit von 1, da wir maximal ein Level repliziert haben möchten und fügen unsere Level-Szene zur Auto Spawn List hinzu. Dabei dient dieses Limit rein zur Absicherung, da unser Skript nur eine Level-Szene instanziiert.

Das Skript main.gd, welches zum Main-Knoten gehört, muss leicht erweitert werden. Die bisherige Funktionalität, die den angepassten Netzwerkpfad konfiguriert und den Server startet, muss nun so angepasst werden, dass sie unsere Level-Szene lädt, sobald der Server gestartet ist.

Konfiguration des LevelSpawners

Konfiguration des LevelSpawners

Um dies zu erreichen, erstellen wir eine neue Funktion change_level() (ab Zeile 14), die die zu ladende Szene als Parameter übergeben bekommt. Um zukünftig auch unterschiedliche Level-Szenen zu unterstützen und gleichzeitig dafür zu sorgen, dass immer nur eine aktiv ist, löschen wir mögliche andere Szenen bzw. Knoten unterhalb des Level-Knotens. Anschließend fügen wir die instanziierte Szene dem Level-Knoten als Kind hinzu.

Es ist wichtig, dass die Szene erst geladen wird, wenn der Server gestartet ist. Ansonsten würde unser LevelSpawner bereits versuchen, das Level zu replizieren, was zu Fehler zur Folge hätte, solange keine Netzwerkverbindung gestartet ist. Daher verknüpfen wir den Aufruf von change_level() mit dem Signal started unseres Servers. Die zu ladende Szene binden wir direkt an das Callable von change_level an.

Nun fehlt nur noch das Signal started unseres Netzwerkservers, damit alles reibungslos klappt. Dabei handelt es sich um ein Signal, welches nach dem Aufruf von create_server emittiert wird, sofern der Rückgabewert OK ist. Um die Übersichtlichkeit dieses Artikels nicht noch weiter zu verschlechtern, habe ich diese zwei Zeilen weggelassen. Man findet sie aber natürlich im zugehörigen Github-Repository.

 1extends Node
 2
 3@onready var world_server := $WorldServer
 4@onready var spawner := $WorldServer/LevelSpawner
 5@onready var level := $WorldServer/Level
 6
 7func _ready() -> void:
 8	get_tree().set_multiplayer(SceneMultiplayer.new(),
 9		^"/root/Main/WorldServer")
10	world_server.started.connect(change_level.bind(
11		load("res://scenes/levels/level.tscn")))
12	world_server.startup()
13
14func change_level(scene: PackedScene):
15	# Remove old level if any.
16	for c in level.get_children():
17		level.remove_child(c)
18		c.queue_free()
19	# Add new level.
20	level.add_child(scene.instantiate())

Integration in den GameClient

Die Herausforderung für die Integration in den GameClient ist es, die Struktur der Main-Szene des WorldServers im GameClient nachzubauen, nachdem wir ein Logintoken vom GatewayServer erhalten haben, aber bevor wir den WorldServer mit dem Logintoken kontaktieren. Aus diesem Grund müssen wir die Funktion _login_callback() im Skript main.gd des GameClients erweitern. Um den Kontext besser verständlich zu machen, habe ich die gesamte Funktion im nachfolgenden Code-Block abgebildet und die neuen Zeilen hervorgehoben. Die Funktion wird aufgerufen, wenn wir im GameClient eine Antwort des GatewayServers erhalten haben. Wenn die Antwort positiv war, wir also auch ein Logintoken bekommen haben, möchten wir mit dem WorldServer in Kontakt treten. Dafür erstellen wir den WorldServer-Knoten, verknüpfen das zugehörige Skript, hinterlegen eine neue Instanz von MultiplayerAPI für den WorldServer-Knoten und die notwendigen Daten bzw. den Callback.

Nun kommt die Erweiterung: Wir bauen die Struktur der Main-Szene des WorldServers nach. Damit das gelingt, müssen wir einen Node namens Level und einen MultiplayerSpawner namens LevelSpawner unterhalb vom WorldServer-Knoten hinzufügen. Zusätzlich müssen wir unseren LevelSpawner noch passend konfigurieren (Zeilen 20-22).

 1func _login_callback(return_code: int, token: String) -> void:
 2	gateway_server.queue_free()
 3	gateway_server = null
 4	if return_code == OK:
 5		print("Current Token is: %s" % token)
 6		world_server = Node.new()
 7		world_server.name = "WorldServer"
 8		add_child(world_server)
 9		world_server.set_script(load("res://scenes/network/world_server.gd"))
10		get_tree().set_multiplayer(SceneMultiplayer.new(), ^"/root/Main/WorldServer")
11		world_server.world_server = world_server_data
12		world_server.callback = _world_server_callback
13
14		var levelNode = Node.new()
15		levelNode.name = "Level"
16		world_server.add_child(levelNode)
17		var levelSpawner := MultiplayerSpawner.new()
18		levelSpawner.name = "LevelSpawner"
19		world_server.add_child(levelSpawner)
20		levelSpawner.spawn_path = levelNode.get_path()
21		levelSpawner.spawn_limit = 1
22		levelSpawner.add_spawnable_scene("res://scenes/levels/level.tscn")
23
24		world_server.login_to_server(token)
25	else :
26		print("Something went wrong…")
27		get_node("LoginScreen").reset()

Sind diese Änderungen gemacht, sollte dem Testlauf eigentlich nichts mehr im Wege stehen. Ich hoffe, dass ich nichts vergessen habe und es auch soweit verständlich war. Ansonsten gibt es das fertige Projekt im üblichen Github-Repository.