Wir entwickeln ein Halloween VR Spiel mit Unity 6, XR Interaction Toolkit 3 und Visual Scripting – Teil 6

In Teil 5 haben wir einen Skelett-Gegner hinzugefügt, mit Bewegungs- und Angriffs-Animationen ausgestattet und ein kleines Visual Script zum Bewegen entwickelt. Heute wollen wir dafür sorgen, dass die Angriffs-Animationen zum Einsatz kommen und auch der Angriff einen Effekt erzielt.

Script Graphs vs. State Graphs

Wie im vorherigen Teil beschrieben, soll das Skelett entweder umherwandern oder den Gegner angreifen. Das klingt, als müssten wir auf der Visual Scripting Seite das abbilden, was wir im Animator schon erledigt haben: eine Unterscheidung von States!

Und jetzt gilt es, eine Entscheidung zu treffen! Es ist vielleicht keine Überraschung, dass Unity’s Visual Scripting auch sogenannte State Graphs unterstützt. Die bestehen aus States, hinter denen jeweils ein Script Graph liegt, und Transitions, hinter denen jeweils auch ein Script Graph liegt (nämlich der, der, ähnlich wie beim Animator Controller, die Conditions für eine Transition implementiert). Die Entscheidung ist also, ob wir nun zu State Graphs migrieren und die beiden Zustände des Skeletts, Herumwandern und Angreifen, jeweils in eigenen States abzubilden, oder ob wir beim bisherigen Script Graph bleiben und die Unterscheidung durch ein einfaches “If” lösen.

Da das hier nunmal nicht nur ein Visual Scripting Tutorial ist, sondern das Ziel vor allem ist, ein Spiel fertig zu bekommen, entscheide ich mich zunächst für das “If”! Wir werden aber deutlich sehen, wann und warum State Graphs ihre Vorteile haben.

Setzen und Speichern des Angriffs-Zustands

Wenn wir keinen State verwenden, um den Angriffs-Zustand eines Skeletts zu repräsentieren, dann brauchen wir ein anderes Mittel. Wir haben im Script Graph bereits mit Boolean Variablen gearbeitet, also Werten, die True oder False sein können. So etwas führen wir auch für den Angriff ein: wenn eine Variable IsAttacking des Skeletts “True” ist, dann führen wir die Angriffs-Logik aus; wenn sie “False” ist, die reguläre Wander-Logik.

Irgendwann werden wir mehrere Skelette in der Szene haben und dann mag der Zustand pro Skelett unterschiedlich sein. Deshalb legen wir diese Variable als sog. Object-Variable an: der Wert wird lokal im (Game)Object gespeichert.

Die Variablen finden sich links unten in der Script Graph Ansicht. Öffne den “Skeleton” Script Graph aus dem Assets/030_Scripts Verzeichnis, klicke auf das “Object” Tab, gib “IsAttacking” als Name der Variable ein und klicke auf das “+” Symbol. Danach kannst du den Typ (“Boolean”) und initialen Wert der Variable festlegen:

Jetzt fügen wir zwischen dem bisherigen “On Update” Einstieg und der “Cooldown” Node das besagte “If” ein (1), das steuert, welches Verhalten das Skelett durchführt. Als Eingabe für das “If” nutzen wir den aktuellen Zustand der neu angelegten Object Variable (2). Wenn das Ergebnis “False” ist (d.h. das Skelett ist aktuell nicht im Angriffsmodus), setzen wir den Ablauf beim “Cooldown” Node für das Herumwandern fort. Wenn es “True” ist, bauen wir dort jetzt die Angriffslogik an.

Bevor wir allerdings implementieren, was beim Angriff passieren soll, müssen wir irgendwie in diesen Zustand kommen. Wir sind noch nicht weit genug, um das Skelett mit unserem Schwert anzugreifen und irgendeinen Effekt zu erzielen. Aber wir können sehr leicht feststellen, dass der Spieler dem Skelett zu nahe gekommen ist!

Das Schlüsselwort hierzu ist ein Trigger-Collider. Wir können um das Skelett herum einen Sphere-Collider legen und diesen als Trigger markieren. Das bedeutet, niemand kollidiert damit, aber sobald ein Kontakt festgestellt wird, können wir das Ereignis als Trigger verwenden und im Script Graph verarbeiten.

Markiere das Skeleton_Minion-GO in der Hierarchy View und füge dann im Inspector einen Sphere Collider hinzu (1). Wenn du im Scene View Gizmos aktiviert hast, siehst du den Collider. Damit das Skelett etwas früher reagieren kann, müssen wir den Radius etwas vergrößern und auch die Position etwas verschieben. Ich habe hier das “Center” auf (0, 1, 0) gelegt und den Radius auf 5. Außerdem habe ich die Checkbox bei “Is Trigger” aktiviert:

Die Berührung eines anderen Objekts mit diesem Collider können wir jetzt im Script Graph implementieren. Wechsele zurück zum “Skeleton” Script Graph und füge einen neuen “On Trigger Enter” Node ein (1). Füge dort dann einen “Set Object Variable” Node an (2), wähle “IsAttacking” als die Variable aus, die zu setzen ist (3) und wähle einen “Boolean Literal” Node als Eingabe für den zu setzenden Wert (4). Markiere die Checkbox, so dass unsere Variable auf “True” gesetzt wird (5).

Wenn wir jetzt das Spiel starten würden, würde der Trigger direkt auslösen und das Skelett würde nicht mehr auf der Ebene wandern. Warum? Weil der Collider auch mit dem Untergrund kollidiert und wir noch keinerlei Unterscheidung haben, was eigentlich die Kollision ausgelöst hat.

Es gibt viele Wege, das zu lösen, aber einer der einfachsten ist die Nutzung von Tags. Tags sind wie Etiketten, von denen man genau eine an ein GameObject hängen kann. Unity hat eine Reihe von solchen Etiketten vordefiniert und wir können unserem Spieler-GO, also unserem “XR Origin (XR Rig)” Prefab aus dem XRIT, die “Player” Etikette anhängen – oder: es als “Player” taggen:

Jetzt können wir in der On Trigger Enter auch noch das Tag des Colliders abfragen und prüfen, ob es der Spieler war, mit dem wir kollidiert sind. Dazu nutzen wir den “Compare Tag” Node (1): der erhält ein GameObject, dessen Tag er mit einem vorgegebenen Tag vergleichen soll. Damit wir das GameObject aus dem Collider bekommen, benötigen wir den “Collider Get Game Object” Node (2). Als zu vergleichendes Tag können wir den Wert “Player” hardcoden (3). Wenn das verglichene GameObject dieses Tag hat, ist das Ergebnis am Ausgang “True” – das nutzen wir dann in der “If” Node (4), wo wir nur genau dann fortsetzen. Wenn das Ergebnis “False” ist – also für jedes andere Objekt als unseren Player – endet die Logik einfach.

Wenn jetzt der Sphere Trigger-Collider des Skeletts beim Herumwandern durch den Spieler getriggert wird, wird die “IsAttacking” Variable auf “True” gesetzt:

Falls das im Video nicht gut zu sehen ist, kurz zur Erklärung: in der Szene befindet sich das Player Rig dort, wo die vielen kleinen Icons zu sehen sind. Vor dem Spieler liegt das Schwert-Objekt. Rechts im Video sieht man, wie der Trigger ausgelöst wird und die Nodes ablaufen und ihre Ergebnisse durchreichen, wenn das Skelett sich in der Nähe des Spielers befindet, und dann sieht man in den Object-Variables den Wert von “IsAttacking” auf “True” wechseln – die Checkbox ist aktiviert.

Allerdings beendet das Skelett erst seinen aktuellen Weg komplett, bis der Wechsel in den “Attacking” Zweig der Logik stattfindet. Daher sollten wir an die Trigger-Logik noch anhängen, dass der Nav Mesh Agent sofort stoppt. Füge ans Ende noch einen “Nav Mesh Agent Set Stopped” Node an:

Angriffslogik

Die Angriffslogik soll ungefähr so aussehen:

  1. Wenn das Angriffsziel in ausreichender Nähe ist und das Skelett zu einem Angriff bereit ist, soll es angreifen.
  2. Wenn das Angriffsziel zu weit entfernt ist, soll das Skelett sich zum Ziel bewegen.
  3. Wenn das Angriffsziel keine Energie mehr hat, soll das Skelett vom Ziel ablassen.

Den dritten Punkt werden wir gar nicht ausimplementieren, denn wenn der Spieler keine Energie mehr hat, ist das Spiel zu Ende – es spielt keine Rolle mehr, was das Skelett dann macht.

Aber für alle Punkte fehlt uns noch eine Information! Wer ist das Angriffziel?! Wir haben zwar die Kollision des Spielers mit dem Trigger-Collider des Skeletts bemerkt und verarbeitet, wir haben uns aber nicht den Spieler gemerkt. Woher weiß das Skelett also nun im “Update”, wen es angreifen soll?

Eine Option wäre das Speichern des aktuellen Ziels in einer Object Variable. So könnte jedes Skelett ein anderes Ziel speichern. Da unsere Skelette aber nur den Spieler angreifen sollen, machen wir es uns noch etwas einfacher: wir speichern den Spieler in einer Scene Variable.

Warum greifen wir nicht direkt auf den Spieler zu? Weil die Script Graphs selbst keine Szenenobjekte sind, sondern Prefabs – sie existieren zunächst nicht direkt in der Szene, sondern im Asset-Ordner, und werden erst zur Laufzeit von der Script Machine geladen. Deshalb kann der Script Graph nicht direkt ein Objekt in der Szene referenzieren, sondern benötigt die Indirektion über die Scene Variable. Dann muss der Script Graph sich nur darauf verlassen, dass diese Scene Variable existiert, aber das Befüllen mit der richtigen Objekt-Referenz muss dann aus der Szene passieren.

Klicke im Blackboard des Scene Graphs auf “Scene” und füge eine neue Variable names “player” vom Typ GameObject hinzu:

Damit dieses Feld nicht leer bleibt, müssen wir es irgendwo befüllen. Beim ersten Hinzufügen einer Script Machine, ein paar Teile zuvor beim Schwert, wurde automatisch ein “VisualScripting SceneVariables” GO zur Hierarchy hinzugefügt. Wenn wir das öffnen, können wir im Inspector neben der Transform-Komponente zwei weitere sehen: Variables und Scene Variables. Wenn wir die Variables-Komponente aufklappen, sehen wir die gerade angelegte “player” Variable mit einer “Value” “None (Game Object)”. In dieses Feld können wir einfach unser “XR Origin (XR Rig)” GO ziehen:

Wo wir jetzt also davon ausgehen können, dass diese Variable ab Spielstart zur Verfügung steht, können wir wieder zum Skelett Script Graph zurückkehren und die Nodes nach dem “If” ausmodellieren, wo “IsAttacking” geprüft wird.

Wir beginnen mit dem Prüfen des Abstands des Skeletts zum “Player”. Sobald das Skelett nah genug ist, kann ein Angriff durchgeführt werden. Wir benutzen diesmal nicht sofort am Anfang der Logik eine “Cooldown” Node, denn die Abstandsprüfung soll jederzeit stattfinden, wenn das Skelett im Angriffsmodus ist: der Spieler könnte sich schließlich jederzeit nähern oder entfernen. Wir werden allerdings einen Cooldown nutzen, um nicht ständig, pro Frame/Update, einen Angriff auszulösen.

Als Eingabe für den “Vector 3 Distance” Node (1) nutzen wir zwei “Transform Get Position” Nodes. Eine Position ist die Position des Skeletts, also “This” (2). Die andere Position (3) ist die des “player” GameObjects aus der Scene Variable, das wir erst per “Get Scene Variable” Node holen müssen (4). Der Flow wird über den “True” Ausgang des ersten “If” in unserem “On Update” Flow eingeleitet (5).

Nun können wir den Abstand mit einem fest definierten Wert vergleichen, um zu entscheiden, ob das Skelett sich näher an den Spieler bewegen soll oder angreifen kann. Ich habe etwas experimentiert und nutze 0.5 als Schwellwert.

Wenn der Abstand größer ist, nutze ich den bereits bekannte “Nav Mesh Agent Set Destination” Node um das Skelett Richtung Spieler zu bewegen. Solange der Spieler weit entfernt ist, würde das allerdings in jedem Update aufgerufen, und das macht keinen Sinn: das Skelett soll schon auf Richtungsänderungen und Ausweichen des Spielers reagieren und den Kurs korrigieren, aber ein ständiges Neuberechnen ist unnötig. Also nutze ich auch hier einen Cooldown mit Wert 0.25, so dass die Berechnung nur noch 4x pro Sekunde passiert.

Die Fortsetzung nach dem “Vector 3 Distance” Node sieht also so aus: Vergleich des Abstands mit Wert 0.5 (1), If-Node (2), falls Abstand “größer” Cooldown (3) und setzen des neuen Ziels auf Basis der Position unserer “player” Scene Variable.

Im letzten Kapitel haben wir den Nav Mesh Agent bei “On Trigger Enter” gestoppt. Alleine das Ziel setzen bringt daher nichts: wir müssen den Nav Mesh Agent auch wieder starten – also diesmal den “Nav Mesh Agent Set Stopped” Node mit “False” als Eingabe nutzen:

Der andere Ausgang des “If” Node ist dann recht einfach: das Skelett ist nah genug am Spieler, könnte angreifen, also machen wir auch da noch ein Cooldown mit z. B. 3 Sekunden und lösen entweder den “Attack1” oder den “Attack2” Trigger auf dem Animator Controller aus.

Um die Angriffsanimation auszuwählen können wir mit dem “Random” Node eine Zufallszahl zwischen 0 und 1 ermitteln. Das Ergebnis können wir in einem “Switch On Integer” Node verarbeiten: der hat dann so viele Ausgänge, wie wir vorgeben. Und dabei treffen wir auf etwas, das wir bisher noch nicht gemacht haben: der Node muss im Graph Inspector konfiguriert werden.

Den hier beschriebenen Teil findest du innerhalb der großen Markierung (1): Cooldown, Random Range, Switch On Integer. Mit markiertem “Switch On Integer” Node kannst du links nun die zwei erwarteten Werte hinterlegen (2), 0 und 1. Damit werden dann an dem “Switch On Integer” Node zwei weitere Ausgänge neben dem “Default” angelegt.

An die Ausgänge hängen wir dann also noch zwei “Animator Set Trigger” Nodes und hinterlegen dort “Attack1” und “Attack2” als Trigger-Namen:

Wenn wir das Spiel jetzt starten würden, dann würde das Skelett, sobald es uns innerhalb seines Trigger-Colliders entdeckt, auf uns zulaufen und sogar direkt in uns hineinlaufen, denn wir nutzen als Zielposition die aktuelle Spielerposition und das Skelett hat bisher außer dem Trigger noch keinen anderen Collider, der tatsächlich mit dem Spieler kollideren könnte.

Den werden wir noch brauchen, aber bis dahin gibt es auch noch einen anderen Weg, um das Skelett etwas auf Abstand zu halten. Der Spieler kann, während er sich über das Spielfeld bewegt, ein Loch im NavMesh erzeugen. Das macht ohnehin Sinn, denn Skelette (und was wir sonst noch alles hinzufügen mögen) sollen ja nicht durch den Spieler hindurch navigieren.

Dazu hängen wir an das “XR Origin (XR Rig)” GO die Nav Mesh Obstacle Komponente. Das “Shape” ändern wir von “Box” zu “Capsule” und das “Center” versetzen wir etwas nach oben auf (0, 1, 0). Außerdem muss die Checkbox bei “Carve” aktiviert werden und dann “Carve Only Stationary” deaktiviert – wir wollen, dass dieses Loch stets geschnitten wird, auch wenn der Spieler in Bewegung ist.

Wenn wir jetzt das “Static” GO markieren, können wir sehen, wie der Spieler an seiner aktuellen Position ein Loch in das NavMesh schneidet:

Jetzt ist die nächste Position des Skeletts durch den Radius dieses Nav Mesh Obstacles beschränkt.

Wenn wir das jetzt mal in VR testen, sehen wir, wie das Skelett uns verfolgt und auch zuschlägt. Aber dadurch, dass der Nav Mesh Agent nun einfach nur so nah wie möglich an das Ziel heranzukommen versucht aber keine Information darüber hat, wohin er danach blicken soll, wirkt das noch etwas komisch:

Das Problem können wir auch lösen, auch wenn die Lösung aufgrund der nötigen Schritte sehr kompliziert wirkt, ohne dass sie es eigentlich ist.

Wir wollen, dass das Skelett sich zum Spieler dreht, wenn es 1. im Angriffs-Modus ist und 2. es keinen Pfad mehr hat, d.h. am Spieler angekommen ist. Die richtige Rotation ermitteln wir, indem wir die aktuelle Position des Spielers von der aktuellen Position des Skeletts abziehen und das Ergebnis als Forward-Vektor bei der Initialisierung eines Quaternions betrachten. Die aktuelle Rotation des Skeletts können wir ebenfalls als Quaternion erhalten. Dann können wir von aktueller zu gewünschter Rotation über die Zeit interpolieren (sog. Slerp).

Beginnen wir mit dem ersten Teil, dem Prüfen der Bedingungen: wir legen dafür einen separaten “On Update” Node an und nutzen zwei “If” Nodes. Wichtig, der Flow geht beim zweiten “If” nur bei “False” weiter: wir wollen nur rotieren, wenn das Skelett sich nicht mehr über die Karte bewegt.

Als nächstes ermitteln wir die Wunsch-Rotation aus dem Delta von Spieler- und Skelett-Position:

Die “Quaternion Look Rotation” interpretiert das Delta zwischen Spieler und Skelett als Forward-Vektor, nutzt (0, 1, 0) als Up-Vektor, und berechnet daraus die Rotation.

Abschließend “slerpen” wir dann von der aktuellen Rotation des Skeletts zur zuvor berechneten Wunschrotation mithilfe des “Quaternion Slerp Unclamped” Node. “Slerp” steht für Spherical Linear Interpolation und ist eine Methode, um zwischen zwei Rotationen in Form von Quaternions zu interpolieren. Als Zeit nutzen wir die Dauer des letzten Frames in Sekunden mithilfe der “Time Get Delta Time” Node und multiplizieren den Wert mit 2 um die Drehung etwas zu beschleunigen. Das Ergebnis setzen wir als neue Rotation des Skeletts. Da eine der Startbedingungen dieses Blocks war, dass das Skelett nicht mehr durch die Welt navigiert, gibt es auch keinen Konflikt mit der Rotation, die durch den Nav Mesh Agent gesetzt werden könnte.

Inzwischen haben wir im Script Graph des Skeletts mehrere Logikblöcke:

  1. Den Einstieg bei “On Trigger Enter”, wenn das Skelett den Spieler entdeckt
  2. Den Einstieg bei “On Update”, wo wir zwischen “IsAttacking” True/False unterscheiden und von dort aus zwei längere Befehlsketten zum Bewegen oder Angreifen auslösen
  3. Den Einstieg bei einem weiteren “On Update”, um regelmäßig die Geschwindigkeit des Nav Mesh Agents in den Animator zu propagieren
  4. Den neuen Einstieg bei “On Update” zum Rotieren des Skeletts zu seinem Gegner

Um das in Gänze zu zeigen, muss ich inzwischen in der Ansicht herauszoomen:

Wir haben hier:

  1. Das On Start Event, bisher ohne Funktion.
  2. Das On Trigger Enter Event, das das Skelett in den Angriffsmodus schaltet, wenn der Spieler den Trigger-Bereich betritt
  3. Das On Update Event mit Angriffs- (oberer Bereich) und Bewegungslogik (unterer Bereich)
  4. Das On Update Event zum Rotieren des Skeletts zum Spieler
  5. Das On Update Event mit Setzen der aktuellen Geschwindigkeits des Skeletts in den Animator Controller

Es macht Sinn, diese Blöcke zu gruppieren, aber da hier noch alles im Fluss ist, warten wir damit lieber noch etwas.

HP und Schaden des Skeletts

Wir sind jetzt so weit, uns um HP (Lebenspunkte, Health Points) und Schaden des Skeletts kümmern zu können. Beginnen wir damit, dass wir das Skelett treffen und erledigen können, bevor wir uns um die andere Richtung, Skelett trifft Spieler, kümmern.

Ragdolls

Das Skelett hat bisher lediglich den Trigger-Collider und einen kinematic Rigidbody. Das ist nichts, das der Spieler mit seinem Schwert treffen könnte. Es gibt zwei Möglichkeiten, wie wir das ändern können:

  1. Wir verpassen dem Skelett einen Capsule Collider, direkt in Form der entsprechenden Komponente, oder indirekt in Form eines Character Controllers, der selbst ein Capsule Collider ist. Dieser Capsule Collider würde mit Zentrum, Höhe und Radius so angepasst, dass er die Form des Skeletts (mit angelegten Armen) ungefähr annähert.
  2. Wir machen aus dem Skelett eine Ragdoll. Als Ragdoll bezeichnet man ein spezielles Setup eines Character-Rigs, bei dem relevante Bones eigene Rigidbodies und Collider erhalten, die durch Joints miteinander verbunden werden. Im Falle des Skeletts finden wir das Rig im gleichnamigen Child-GO und hier könnte zum Beispiel das Child “head” mit einem Collider ausgestattet werden, der dann die Form und das relative Gewicht des Kopfs approximiert.

Der Weg über den Character Controller wäre unnötig. Den würde man eher verwenden, wenn der Spieler das Skelett selbst steuert. Die wichtigsten Eigenschaften des Character Controllers sind bereits über den Nav Mesh Agent abgehandelt.

Ein Capsule Collider wäre möglich. Der bringt aber den Nachteil mit sich, dass die Kollisionsfläche des Skeletts eben genau diese Kapsel ist. Ragt ein Arm aus dieser Kapsel heraus und der Spieler versucht, den Arm mit seinem Schwert zu treffen, geht der Hieb ins Leere. Das stört die Immersion extrem.

In VR kommt man um Ragdolls für Charaktere kaum herum. Dadurch, dass Kopf, Torso sowie Arme und Beine mit eigenen Collidern ausgestattet werden, ist das Feedback beim Zuschlagen viel näher an dem, was man gerade sieht.

Glücklicherweise kommt Unity mit einem kleinen Helfer, um ein Ragdoll zu konfigurieren. Öffne zunächst die Hierarchy unter dem Skeleton_Minion/Rig GO. Klicke dann mit Rechts auf das Skeleton_Minion-GO und wähle 3D Object → Ragdoll… Dann öffnet sich ein Fenster mit allerlei Object Inputs. Setze die Felder so, wie im folgenden Screenshot gezeigt, indem du die jeweiligen GOs (Bones) aus dem Rig in die Felder ziehst:

Die übrigen Hinweise im Fenster sind gegeben: das Skelett befindet sich in der “T-Pose” und die blaue Achse zeigt in Blickrichtung. Wenn alle Felder zugewiesen sind, können wir “Create” klicken, um die Ragdoll-Komponenten anzulegen.

Wenn du Gizmos aktivierst, siehst du, wie nun verschiedene Collider auf den Köperteilen des Skeletts angelegt wurden. Box Collider für Hüfte und Torso, Capsule Colliders für Ober- und Unterarme und -beine, Sphere Collider für den Kopf. Da das Skelett einen stilisierten, großen Schädel hat, passt letzterer ganz und gar nicht und wir können ihn manuell verbessern. Klicke auf das “head” GO und ändere in der Sphere Collider Komponente Center zu (0, 0.5, 0) und Radius zu 0.4. Wenn du die Änderungen selbst durch Ausprobieren durchführen möchtest, hilft es, wieder in die orthografische Projektion zu wechseln und den Kopf von vorne und von der Seite zu betrachten. Dann sollte es so aussehen:

Im Screenshot sieht man es nicht gut, aber der Sphere Collider passt jetzt besser zum Schädel. Für das beste Ergebnis könnte man die Collider weiter optimieren, aber da Halloween bald vor der Tür steht, machen wir erstmal weiter!

Wenn du das Spiel nun im Editor startest und das Skelett im Scene View verfolgst, wirst du feststellen, dass einzelne Teile gelegentlich ziemlich wild herumspringen. Das liegt daran, dass die Physik der Ragdoll Rigidbodies und der Animator Controller, der die Bones des Skeletts bewegt, gegeneinander wirken. Für den Augenblick behält der Animator Controller die Führung, daher können wir die Rigidbodies der Ragdoll auf “kinematic” setzen. Außerdem macht es Sinn, bei dieser Gelegenheit die Collision Detection zu “Continuous Dynamic” zu wechseln.

Markiere aus dem Rig des Skeletts die “spine”, “head”, “upperarm.l”, “lowerarm.l”, “upperarm.r”, “lowerarm.r”, “upperleg.l”, “lowerleg.l”, “upperleg.r” und “lowerleg.r” GOs und nimm die Änderungen vor:

Dem Schwert des Skeletts fehlt auch noch Rigidbody und Collider. Markiere also das Schwert und füge einen Box Collider hinzu. Passe ihn an, wie wir das auch beim Schwert des Spielers gemacht haben. Ich habe mir den Spaß gemacht, und diesmal zwei Box Collider angelegt und habe zum Anpassen der Größen wieder in die orthografische Projektion gewechselt. Füge außerdem einen Rigidbody hinzu und setze “Mass” auf 10 und “Collision Detection” auf “Continuous Dynamic”.

Enemy-Tag

Der Spieler kann auf vielerlei Arten Schaden anrichten: er kann das Schwert schwingen, er kann aber genau so gut auch das Schwert auf das Skelett werfen. Daher macht es Sinn, wenn die Kollisionserkennung im Schwert des Spielers stattfindet. Wenn dieses den “Körper” des Skeletts trifft, soll es Schaden anrichten. Wenn es allerdings das Schwert des Skeletts, oder vielleicht später noch sein Schild trifft, soll es das nicht; ebensowenig wie, wenn es beim Herunterfallen mit dem Boden kollidiert.

Um das unterscheiden zu können, fügen wir ein neues Tag ein. Klicke auf das “Skeleton_Minion” GO und dann im Inspector auf das Tag-Dropdown. Klicke dann unten auf “Add Tag…”. Es wird daraufhin die Tags & Layers Ansicht geöffnet, die du übrigens auch über die Project Settings erreichen kannst.

Klicke auf das “+” bei “Tags” um ein neues Tag anzulegen und nenne es “Enemy”:

Klicke nun rechts auf das “Skeleton_Minion” GO in der Hierarchy View und wähle “Select Children”. Du siehst nun, dass alle Child-GOs des Skeletts ausgeklappt wurden und markiert sind. Entferne lediglich das Skeleton_Blade-GO wieder aus der Auswahl, denn wenn wir das Schwert des Skeletts treffen, schadet das dem Skelett nicht:

Wähle dann rechts im Inspector das neue “Enemy” Tag aus. So kann das Schwert mit jedem Collider aus dem Skelett kollidieren und direkt feststellen, ob es sich beim Kollisionsobjekt um einen Gegner handelt.

Für das Schwert des Spielers hatten wir ja bereits einen Script Graph gestartet, um das Reparenting beim Greifen durchzuführen. Öffne diesen Script Graph, indem du entweder auf das “Sword” GO klickst und dann im Inspector in der Script Machine Komponente “Edit Graph” klickst oder indem du den Script Graph im Project View unter Assets/030_Scripts/Sword per Doppelklick öffnest.

An eine freie Stelle fügen wir jetzt einen neuen Node “On Collision Enter Event” ein (1). Dieser Event wird ausgelöst, wenn das Schwert mit einem anderen Objekt kollidiert – wie z. B. dem Boden oder dem Skelett. Damit wir das eine vom anderen unterscheiden können, nutzen wir das gerade angelegte Tag. Der Event liefert uns den Collider, von dem wir das GameObject ermitteln können (2). Das geben wir wieder als Eingabe in eine “Game Object Compare Tag” Node (3), die mit dem Wert “Enemy” vergleicht. Das Ergebnis wird per “If” Node überprüft (4) und wir setzen nur fort, falls die Bedingung erfüllt ist. Da der Collider auf einem der Child-GOs des Skeletts liegt, suchen wir das Parent-GO mit der “Script Machine” Komponente (5, 6) und dort machen wir etwas Neues: wir senden über die “Custom Event Trigger” Node eine Nachricht an das Collider GameObject (7).

Spannend zu erwähnen ist hier, dass das Kollisionsevent einige weitere Informationen mitbringt, die wir noch nicht verwenden, z. B. die Impulskraft (Impulse) und Geschwindigkeit (Relative Velocity) der Kollision. Wir könnten also den Effekt des Treffers noch weiter auswerten und z. B. die Kraft (Impulskraft/Zeit) zur Berechnung des Schadens heranziehen. Wir kommen später darauf zurück!

Der letzte Node ist etwas Neues: über Custom Events können wir zwischen verschiedenen Objekten und verschiedenen Script Graphs kommunizieren. Damit wir das Event an die richtige Stelle senden, suchen wir zunächst die Script Machine und senden das Event dann dorthin. Ein Event-Bubbling, wo ein Event automatisch “nach oben” wandert, gibt es hier leider nicht. Auf der Gegenseite benötigen wir nun also etwas, das auf diesen Custom Event “Damage” hört.

Also wechseln wir zum Script Graph des Skeletts und fügen einen “Custom Event” Node ein. Auch hier hinterlegen wir “Damage” als Name. Wir erwarten also, dass dieser Event ausgelöst wird, wenn immer wir einen Collider des Skeletts mit dem Schwert treffen.

Feedback

Bevor wir uns an die HP machen, fügen wir eine Reaktion des Skeletts auf Treffer in den Animator Controller ein. Das ist das visuelle Feedback an den Spieler für einen gelandeten Treffer.

Der Fairness halber muss ich hinzufügen, dass das in VR nur eine Kompromisslösung ist: die Immersion lebt von Genauigkeit und passenden Reaktionen. Wenn wir einen schweren Treffer von der rechten Seite landen, dann sollte sich das Skelett nach links bewegen. Wenn wir das Skelett nur ganz sanft berühren und offensichtlich keinen Schaden zufügen, dann sollte es auch hier entsprechend reagieren. Und je nach dem, wie weit man gehen möchte, sollten Blut oder Knochen an der richtigen Stelle spritzen oder splittern, und ein sauberer Hieb könnte auch Kopf oder Gliedmaßen abtrennen. Eine vollumfängliche Lösung zu entwickeln ist eine der Königsdisziplinen in VR und würde den Rahmen dieses Tutorials sprengen, daher starten wir zunächst mit einer generischen Reaktion. Ich werde aber später noch auf Möglichkeiten eingehen, wie man die Reaktion verbessern kann.

Wir beginnen wieder mit dem Blick in das Model des Skeletts unter Assets/040_Models/Skeleton_Minion. Es enthält zwei “Hit” Animationen, die wir nutzen können. Um sie abzuspielen, wählen wir den gleichen Weg wie beim Angriff: wir legen im Animator Controller unter Assets/070_Animation/Skeleton zwei Trigger, “Hit1” und “Hit2”, sowie zwei Animation States mit den jeweiligen Animationen an.

Die Transition in diese States passiert allerdings diesmal nicht aus dem “Idle” State! Stattdessen nutzen wir die “Any State” Node. So wird die Reaktion unmittelbar ausgelöst, egal ob das Skelett gerade im “Idle”- oder in einem der “Attack”-States ist. “Has Exit Time” ist in dieser Transition standardmäßig bereits deaktiviert. Als Condition verwenden wir “Hit1” bzw. “Hit2”. Eine Transition zurück zum “Any State” ist nicht möglich, wir können daher nach Abspielen der “Hit*” Animation erstmal zum “Idle” State zurückkehren.

So sollte das dann etwa aussehen:

Zum Triggern der Animation im Script Graph des Skeletts nutzen wir das gleiche Verfahren wie bei der Attack-Animation: nach dem “Custom Event” eine Zufallszahl erzeugen und die entsprechende Animation auslösen:

In Aktion sieht das dann so aus:

Das Schwert kollidiert mit den verschiedenen Ragdoll Collidern des Skeletts und bei einem Treffer wird eine Animation ausgelöst. Aber wenn wir das Schwert auf das Skelett “pressen”, wird immer wieder eine Kollision registriert und die Animation getriggert, wie am Ende des Videos zu sehen ist. Hier habe ich die Reaktion provoziert, aber das kann auch passieren, wenn man einen Treffer am Kopf landet und das Schwert dann runter zum Körper rutscht.

Eine mögliche Lösung für dieses Problem kennen wir bereits! Den Cooldown-Node. Wenn das Skelett bereits reagiert, bringt es nichts, die Animation nochmal auszulösen. Also schieben wir einfach noch einen Cooldown ein. Die beiden Animationen sind 0.867 und 0.667 Sekunden lang, wie man nach Klick auf die Animationen im Inspector sehen kann. Also sollten wir mit einer Cooldown-Duration von einer Sekunde gut hinkommen. Aber wie immer kann hier experimentiert werden, damit es sich noch besser anfühlt!

Der Cooldown-Node, extra dramatisch eingefügt.

Eine berechtigte Frage ist, ob irgendeine logische Reaktion auf den Angriff stattfinden sollte.

Der Spieler könnte sich z. B. von hinten an das Skelett anschleichen und ihm mit dem Schwert etwas über den Schädel ziehen! Als Reaktion sollte das Skelett doch zumindest noch in den Angriffsmodus wechseln!

Oder…?

Nein, das ist nicht nötig. Dadurch, dass wir einen Sphere Trigger-Collider eingesetzt haben, wird der Angriffsmodus immer aktiviert.

Wenn du das Spiel weiter verfeinern möchtest, könntest du aber die Sphere durch eine andere Form ersetzen und so einen realistischen Sichtradius abbilden. Es gibt bspw. im Asset Store einen kostenlosen Cone Collider*, den du stattdessen nutzen könntest. In diesem Fall müsstest du dann in unserer neuen Logik auch noch die “IsAttacking” Variable auf “True” setzen und, falls sie noch nicht gesetzt war und das Skelett einfach gerade umherwandert, auch noch den Nav Mesh Agent stoppen.

Abgesehen vom visuellen und logischen Feedback fehlt auch noch akustisches und haptisches Feedback. Dazu kommen wir später.

HP

Wir haben schon mehrfach über Variablen gesprochen und wie man sie anwendet. Den Spieler speichern wir in einer Scene Variable, weil jeder Gegner da gleichermaßen drauf zugreift. Die “IsAttacking” Variable speichern wir je Skelett als Object Variable. Bei den HP verhält es sich genau so, da der Wert zwar für jedes Skelett gleich startet, sich dann aber über die “Lebenszeit” des Skeletts unterschiedlich verändern kann.

Wenn wir in der Hierarchy View das “Skeleton_Minion” GO öffnen, sehen wir im Inspector die Variables Komponente. Wir fügen einfach neben die “IsAttacking” Variable noch eine “HP” Variable ein, diesmal vom Typ “Integer” und mit einem Startwert von… sagen wir… 50? Auch hier bist du frei, mit dem Wert in Abhängigkeit der folgenden Berechnungen zu experimentieren.

Diesen Wert können wir jetzt in unserer “Custom Event” Script Graph Logik verringern. Fürs Erste reduzieren wir die HP Variable bei einem Treffer um einen festen Wert, z. B. 10. Damit benötigen wir 5 Treffer, um das Skelett zu erledigen. Wir nutzen den “Set Variable” Node um der Object Variable einen neuen Wert zuzuweisen und als Eingabe nutzen wir den aktuellen Wert, von dem wir 10 abziehen.

Im Moment würde der Wert ungehindert reduziert werden. Es fehlt also noch eine letzte “If” Node…

Tod

Das Skelett “stirbt”, sobald HP <= 0 ist. Und ab dann werden viele Dinge nicht mehr benötigt: Keine Bewegung, kein Angriff, keine Reaktion auf Schwerthiebe des Spielers, selbst die Aktualisierung des “Speed” Parameters im Animator Controller wird überflüssig.

Spätestens jetzt wird deutlich, wo die Vorzüge eines State Graphs liegen! Condition für den Wechsel in einen “Death” State wäre etwas wie “ObjectVariable[HP] <= 0” und ab dann würde das Skelett einfach nichts anderes mehr machen, weil der neue State leer ist und die ganze aktuelle Logik, die wir im Script Graph gebündelt haben, nicht mehr zum Tragen kommt.

Wir haben aber keinen State Graph, also benötigen wir ein paar Abfragen. Um es etwas einfacher zu machen, führen wir eine weitere Object Variable “IsDead” vom Typ “Boolean” ein, wieder auf dem “Skeleton_Minion”-GO.

Diese Variable können wir nun setzen, wenn HP <= 0 ist. Und wir können sie auch direkt zu Beginn des Custom Event abfragen, denn… wenn das Skelett bereits tot ist, brauchen wir keine HP mehr reduzieren.

Wir fügen also zu Beginn einen “If” Node ein (1) und setzen nur fort, wenn “IsDead” noch “False” ist. Außerdem können wir die berechnete neue HP direkt in einen “Less Or Equal” Vergleich schleifen und ebenfalls in eine weitere “If” Node eingeben (2). Wenn das Ergebnis hier “False” ist, spielen wir die regulären Hit-Animationen. Wenn das Ergebnis aber “True” ist, setzen wir die “IsDead” Variable auf “True”. Hier werden wir gleich anschließen, aber vorher müssen wir auch in den Rest der Logik dieses Script Graphs gucken.

Es gibt zwei Stellen, wo wir “IsDead” prüfen sollten: beim Rotieren des Skeletts Richtung Ziel und zu Beginn des großen Logikblocks mit Bewegungs- und Angriffslogik. Weiter oben in diesem Artikel habe ich den bisherigen Script Graph in mehrere Teile eingeteilt: ich rede hier von den Teilen 3 und 4.

Block 3, die allgemeine Bewegungs- und Angriffslogik, beginnt nun so:

Block 4, die Rotation des Skeletts zum Spieler im Angriffsmodus, beginnt nun so:

Wichtig ist, dass wir den Flow jeweils nur im “False” Fall fortsetzen!

Jetzt kehren wir wieder zurück zur Verarbeitung des Treffers und zu dem Node, wo wir “IsDead” auf “True” setzen. Es fehlt ja noch etwas! Zum einen könnte das Skelett noch herumlaufen, selbst ohne aktive Bewegungslogik könnte der Nav Mesh Agent gerade noch ein Ziel haben. Also stoppen wir den direkt. Dann sollte irgendetwas mit dem Skelett passieren und basierend auf den bisherigen Events wie Angriff und Treffer wäre der naheliegende Schritt, eine Sterbe-Animation abzuspielen. Tatsächlich finden sich im Modell entsprechende Animationen.

Wir gehen aber einen anderen Weg und lernen jetzt einen weiteren Vorteil der Ragdoll kennen: wir haben die Rigidbodies der Ragdoll auf “kinematic” gesetzt, weil der Animator die Bewegung der Bones steuert. Jetzt gehen wir den umgekehrten Weg: wir deaktivieren den Animator Controller und deaktivieren “Is Kinematic” für alle Rigidbodies. Damit wirkt die Physik auf alle Bones des Skeletts und es fällt in sich zusammen.

Die Fortsetzung der Logik sieht also wie folgt aus: Wir deaktivieren den Nav Mesh Agent komplett (1), damit beeinflusst der auch die Position und Rotation des Skeletts nicht mehr. Wir deaktivieren auch den Animator (2), damit der die Bones nicht mehr bewegt. Dann nutzen wir den “Component Get Components in Children” Node um alle Rigidbody Komponenten in den Children (d.h. im Rig inklusive dem Schwert in der Hand des Skeletts) zu erhalten (3) und iterieren über diese Liste mithilfe des “For Each Loop” Nodes (4). Dieser Node hat zwei Flow-Ausgänge, “Exit” würde aufgerufen wenn die Iteration abgeschlossen ist, wir nutzen stattdessen den “Body” Ausgang, der pro Element aufgerufen wird, das wir wiederum aus dem “Item” Ausgang holen können. Auf diesem Element nutzen wir den “Rigidbody Set Kinematic” Node um “Is Kinematic” für die Bones auszuschalten (5):

Damit sieht der Tod des Skeletts so aus:

Nicht schlecht, oder? Damit beenden wir diesen Teil!

Ausblick

Ich habe die Nase voll von der nackten Unity Szene: es wird Zeit, dass wir im nächsten Teil die weiteren Assets ins Spiel bringen und Skybox und Beleuchtung etwas anpassen, damit wir endlich etwas Halloween-Atmosphäre erzeugen.


* Dieser Artikel enthält sogenannte Affiliate-Links. Wenn du sie klickst und im verlinkten Store etwas kaufst, erhalte ich eine kleine Provision. Für dich ändert sich der Preis nicht und ich erhalte keine Informationen über deinen Warenkorb.

Leave a Reply

Your email address will not be published. Required fields are marked *