Dein erstes Spiel

Übersicht

Diese Anleitung wird Dir dabei helfen, Dein erstes Godot-Projekt umzusetzen. Du wirst lernen, wie der Godot-Editor funktioniert, wie ein Projekt zu strukturieren ist und man ein 2D-Spiel erstellt.

Bemerkung

Dieses Projekt ist eine Einführung in die Godot Engine. Es wird angenommen, dass du bereits etwas Programmiererfahrung hast. Falls Du vorher noch nie programmiert hast, solltest Du hier beginnen: Skripten.

Das Spiel heißt "Dodge the Creeps!". Dein Charakter muss sich bewegen, um den Gegnern so lange wie möglich auszuweichen. Hier eine Vorschau auf das Endergebnis:

../../_images/dodge_preview.gif

**Warum 2D? ** 3D-Spiele sind viel komplexer als 2D-Spiele. Du solltest dich deshalb an 2D halten, bis du ein gutes Verständnis für den Spielentwicklungsprozess hast.

Projektkonfiguration

Starte Godot und erstelle ein neues Projekt. Danach, lade dodge_assets.zip herunter. Die Datei beinhaltet die Bilder und Töne, sogenannte "Assets", die du verwenden wirst, um das Spiel zu erstellen. Entpacke die Dateien in den zuvor gewählten Projektordner.

Bemerkung

In dieser Anleitung gehen wir davon aus, dass Du mit dem Editor vertraut bist. Falls Du Szenen und Nodes noch nicht gelesen hast, informiere Dich darüber, wie Du ein Projekt einrichten und den Editor verwenden kannst.

Dieses Spiel verwendet das Hochformat, daher müssen wir die Größe des Spielfensters anpassen. Klicke auf Projekt -> Projekteinstellungen -> Anzeige -> Fenster und setze "Width" auf 480 und "Height" auf 720.

Auch in diesem Abschnitt, unter den "Streck"-Optionen, setze Modus auf "2d" und Aspect auf "keep". Das stellt sicher, dass das Spiel konsistent auf verschiedenen Bildschirmgrößen skaliert wird.

Projektorganisation

In diesem Projekt werden 3 unabhängige Szenen erstellt: Player, Mob und HUD, die wir in der Main-Szene des Spiels kombinieren werden. In einem größeren Projekt kann es nützlich sein, Ordner für die verschiedenen Szenen und deren Skripte zu erstellen. Bei diesem relativ kleinen Spiel kannst du deine Szenen und Skripte jedoch im Stammordner des Projekts speichern, der als res:// bezeichnet wird. Du kannst Deinen Projektordner im Dateisystem-Dock in der unteren linken Ecke sehen:

../../_images/filesystem_dock.png

Spieler-Szene

Die erste erstellte Szene definiert das Player-Objekt. Einer der Vorteile einer separaten Spieler-Szene ist, dass diese unabhängig vom Rest des Spiels getestet werden kann, dieser muss noch nicht einmal existieren.

Node Struktur

Um zu beginnen, müssen wir einen Wurzel-Node für das Spieler-Objekt wählen. Als eine generelle Regel gilt, ein Wurzel-Node einer Szene sollte immer die gewünschte Funktionalität des Objektes reflektieren - was das Objekt ist. Klicke auf den "Andere Node" - Knopf und füge einen Area2D Node zu der Szene hinzu.

../../_images/add_node.png

Godot zeigt ein Icon mit einem Warnhinweis im Node des Szenenbaumes. Dieses kann vorerst ignoriert werden, wir werden uns später darum kümmern.

Mit Area2D können Objekte erkannt werden, die mit dem Spieler überlappen oder diesen berühren. Wir ändern den Namen des Nodes zu Player, indem wir darauf Doppelklicken. Nun da wir den Wurzel-Node der Szene gesetzt haben, können wir weitere Nodes hinzufügen, um ihm damit mehr Funktionalität zu geben.

Bevor wir weitere untergeordnete Nodes zu Player hinzufügen, wollen wir sicherstellen, dass wir diese nicht aus Versehen bewegen oder deren Größe ändern, indem wir auf sie klicken. Wähle den Node aus und klicke auf das Symbol rechts neben dem Schloss; sein Tooltip lautet "Verhindert das Auswählen von Unterobjekten dieses Nodes."

../../_images/lock_children.png

Speichere die Szene. Klicke auf Szene -> Speichern oder drücke Strg+S unter Windows/Linux oder Command+S unter Mac.

Bemerkung

Für dieses Projekt halten wir uns an die Godot-Namenskonvention.

  • GDScript: Klassen (Nodes) nutzen PascalCase, Variablen und Funktionen snake_case und Konstanten ALL_CAPS (Siehe GDScript Style Guide).
  • C#: Klassen, Exportvariablen und Methoden verwenden die PascalCase-Schreibweise, private Felder _camelCase, lokale Variablen und Parameter verwenden camelCase (Siehe C# Style Guide). Beachte die genaue Schreibweise, wenn Du Signale einbinden möchtest.

Sprite-Animation

Klicke auf den Player Node und füge ein AnimatedSprite Node als Unterobjekt hinzu. Das AnimatedSprite übernimmt das Erscheinungsbild und die Animationen für unseren Spieler. Achte auf das Warnsymbol neben dem Node . Ein AnimatedSprite benötigt eine SpriteFrames Ressource, eine Liste der Animationen, die angezeigt werden können. Um eine zu erstellen suche die Eigenschaft Frames im Inspektor und klicke "<null>" -> "Neue SpriteFrames". Nun sollte sich automatisch das Animationspanel öffnen:

../../_images/spriteframes_panel.png

Auf der linken Seite befindet sich eine Liste von Animationen. Klicke auf die "default" Animation und benenne sie in "walk" um. Dann klicke auf die "hinzufügen" Schaltfläche, um eine zweite Animation "up" hinzuzufügen. Finde die zwei Bilder, playerGrey_up[1/2] und playerGrey_walk[1/2], im "Dateisystem" Reiter und ziehe sie in den "Animationframes" Bereich zu den entsprechenden Animationen:

../../_images/spriteframes_panel2.png

Die Spielerbilder sind ein bisschen zu groß für das Spielefenster, also müssen wir sie verkleinern. Klicke auf den AnimatedSprite Node und setze die Eigenschaft Scale auf (0.5, 0.5). Du kannst sie im Inspektor unterhalb der Überschrift ``Node2D``finden.

../../_images/player_scale.png

Abschließend füge ein CollisionShape2D als ein Unterobjekt von Player hinzu. Er bestimmt die "Hitbox" des Spielers oder die Grenzen seines Kollisionsbereichs. Für diesen Charakter ist ein CapsuleShape2D Node am besten geeignet. Klicke im Inspektor neben "Shape" auf "<null>" -> "New CapsuleShape2D". Verwende die zwei Anfasser und verändere die Form so, dass sie das Sprite überdeckt:

../../_images/player_coll_shape.png

Wenn du fertig bist sollte Deine Player Szene die folgende Struktur haben:

../../_images/player_scene_nodes.png

Stelle sicher, die Szene nach diesen Änderungen wieder abzuspeichern.

Den Spieler Bewegen

Jetzt müssen wir Funktionalität hinzufügen, die wir nicht von einem eingebauten Node bekommen können, also werden wir ein Skript hinzufügen. Klicke auf den Player Node und dann auf die Schaltfläche "Skript hinzufügen":

../../_images/add_script_button.png

Im Fenster mit den Skripteinstellungen kannst Du die Standardeinstellungen beibehalten. Klicke einfach auf "Erstellen":

Bemerkung

Wenn Du ein C#-Skript oder andere Sprachen verwenden willst, wähle die Sprache aus dem Auswahlmenü "Sprache", bevor Du auf Erstellen klickst.

../../_images/attach_node_window.png

Bemerkung

Wenn dies Dein erster Kontakt mit GDScript ist, lies bitte Skripten, bevor Du fortfährst.

Beginne, indem Du die Member-Variablen deklarierst, die dieses Objekt benötigt:

extends Area2D

export var speed = 400  # How fast the player will move (pixels/sec).
var screen_size  # Size of the game window.
public class Player : Area2D
{
    [Export]
    public int Speed = 400; // How fast the player will move (pixels/sec).

    private Vector2 _screenSize; // Size of the game window.
}

Wenn Du das Schlüsselwort export für die erste Variable speed verwendest, ermöglicht es Dir, dessen Wert im Inspektor einzustellen. Dies kann für Werte nützlich sein, die Du wie die integrierten Eigenschaften eines Nodes anpassen möchtest. Klicke auf den Node Player, und die Eigenschaft wird jetzt im Bereich "Skriptvariablen" des Inspektors angezeigt. Denke daran, wenn Du den Wert hier änderst, wird der im Skript verwendete Wert überschrieben.

Warnung

Wenn Du C# verwendest, musst Du die Projekt-Bausteine neu übersetzen, um neue Exportvariablen oder Signale sichtbar zu machen. Das kann manuell durch einen Klick auf das Wort "Mono" im unteren Fensterbereich geschehen, es wird das Mono-Panel eingeblendet und ein anschließender Klick auf "Build Project" führt die Aktion aus.

../../_images/export_variable.png

Die Funktion _ready() wird aufgerufen, wenn ein Node in den Szenenbaum eintritt, was ein guter Zeitpunkt ist, um die Größe des Spielfensters zu ermitteln:

func _ready():
    screen_size = get_viewport_rect().size
public override void _Ready()
{
    _screenSize = GetViewport().Size;
}

Jetzt können wir die Funktion _process() verwenden, um festzulegen, was der Spieler tun soll. _process() wird mit jedem Frame aufgerufen, deshalb werden wir es verwenden, um Elemente unseres Spiels zu aktualisieren, von denen wir erwarten, dass sie sich häufig ändern werden. Für den Spieler müssen wir Folgendes tun:

  • Auf Eingabe prüfen.
  • In die angegebene Richtung bewegen.
  • Die entsprechende Animation abspielen.

Zuerst müssen wir auf Eingaben prüfen - drückt der Spieler eine Taste? Für dieses Spiel haben wir 4 Richtungseingaben zu überprüfen. Eingabeaktionen werden in den Projekteinstellungen unter "Eingabe-Zuordnung" definiert. Du kannst benutzerdefinierte Ereignisse definieren und ihnen verschiedene Tasten, Mausereignisse oder andere Eingaben zuweisen. Für diese Demo werden wir die Standardereignisse verwenden, die den Pfeiltasten auf der Tastatur zugeordnet sind.

Du kannst herausfinden, ob eine Taste gedrückt wird, indem Du Input.is_action_pressed() benutzt, welches true zurückgibt, wenn eine Taste gedrückt wird und false wenn nicht.

func _process(delta):
    var velocity = Vector2()  # The player's movement vector.
    if Input.is_action_pressed("ui_right"):
        velocity.x += 1
    if Input.is_action_pressed("ui_left"):
        velocity.x -= 1
    if Input.is_action_pressed("ui_down"):
        velocity.y += 1
    if Input.is_action_pressed("ui_up"):
        velocity.y -= 1
    if velocity.length() > 0:
        velocity = velocity.normalized() * speed
        $AnimatedSprite.play()
    else:
        $AnimatedSprite.stop()
public override void _Process(float delta)
{
    var velocity = new Vector2(); // The player's movement vector.

    if (Input.IsActionPressed("ui_right"))
    {
        velocity.x += 1;
    }

    if (Input.IsActionPressed("ui_left"))
    {
        velocity.x -= 1;
    }

    if (Input.IsActionPressed("ui_down"))
    {
        velocity.y += 1;
    }

    if (Input.IsActionPressed("ui_up"))
    {
        velocity.y -= 1;
    }

    var animatedSprite = GetNode<AnimatedSprite>("AnimatedSprite");

    if (velocity.Length() > 0)
    {
        velocity = velocity.Normalized() * Speed;
        animatedSprite.Play();
    }
    else
    {
        animatedSprite.Stop();
    }
}

Wir fangen damit an, die velocity auf (0,0) zu setzen - standardmäßig sollte sich der Spieler nicht bewegen. Dann überprüfen wir jede Eingabe und addieren/subtrahieren von der velocity, um eine Gesamtrichtung zu erhalten. Wenn Du beispielsweise rechts und runter gleichzeitig hältst, ist der resultierende Geschwindigkeitsvektor (1, 1). In diesem Fall, da wir eine horizontale und vertikale Bewegung addieren, würde sich der Spieler schneller bewegen, als wenn er sich nur horizontal bewegen würde.

Wir können das verhindern, indem wir die Geschwindigkeit normalisieren, was bedeutet, dass wir ihre Länge auf 1 festlegen, anschließend multiplizieren wir mit der gewünschten Geschwindigkeit. Das sorgt dafür dass keine schnelle diagonale Bewegung mehr stattfindet.

Tipp

Wenn Du noch nie zuvor Vektor-Mathematik verwendet hast oder eine Auffrischung benötigst, findest Du eine Erklärung zur Vektorverwendung in Godot unter Vektormathematik. Es ist gut zu wissen, wird aber für den Rest dieser Anleitung nicht notwendig sein.

Wir prüfen auch, ob sich der Spieler bewegt, damit wir die AnimatedSprite-Animation mit play() und``stop()``, starten oder stoppen können.

$ ist eine Abkürzung für get_node(). Im obigen Code ist $AnimatedSprite.play() dasselbe wie get_node("AnimatedSprite").play().

Tipp

In GDScript gibt $ den Node am relativen Pfad zum aktuellen Node zurück oder null, wenn der Node nicht gefunden wird. Da AnimatedSprite ein Unterobjekt des aktuellen Nodes ist, können wir $AnimatedSprite verwenden.

Jetzt, da wir eine Bewegungsrichtung haben, können wir die Position des Spielers aktualisieren und mit clamp() verhindern, dass er den Bildschirm verlässt. clamp() beschränkt dabei einen Wert auf einen angegebenen Bereich. Am Ende der _process Funktion fügen wir also folgendes hinzufügen:

position += velocity * delta
position.x = clamp(position.x, 0, screen_size.x)
position.y = clamp(position.y, 0, screen_size.y)
Position += velocity * delta;
Position = new Vector2(
    x: Mathf.Clamp(Position.x, 0, _screenSize.x),
    y: Mathf.Clamp(Position.y, 0, _screenSize.y)
);

Tipp

Der Parameter delta in der Funktion _process() bezieht sich auf die Frame-Länge - die Zeit, die der vorherige Frame benötigt hat, um abzuschließen. Die Verwendung dieses Wertes stellt sicher, dass Ihre Bewegung auch bei einer Änderung der Bildrate konstant bleibt.

Klicke "Spiele Szene" (F6) und stelle sicher, dass der Spieler sich auf dem Bildschirm in alle Richtungen bewegen kann.

Warnung

Wenn ein Fehler im "Debugger" Bereich auftaucht, der sagt

Attempt to call function 'play' in base 'null instance' on a null instance

bedeutet das wahrscheinlich, dass der Node-Namen des AnimatedSprite falsch geschrieben wurde. Node-Namen sind case-sensitive und $NodeName oder get_node("NodeName") müssen mit dem Namen übereinstimmen, der im Szenenbaum zu sehen ist.

Animationen

Jetzt, da sich der Spieler bewegen kann, müssen wir ändern, welche Animation der AnimatedSprite je nach Richtung abspielt. Wir haben eine "walk" Animation, die horizontal mit der Eigenschaft flip_h für die Linksbewegung gespiegelt werden sollte, und eine "up"-Animation, die vertikal mit flip_v für die Abwärtsbewegung gespiegelt werden sollte. Lass uns diesen Code an das Ende unserer _process() Funktion setzen:

if velocity.x != 0:
    $AnimatedSprite.animation = "walk"
    $AnimatedSprite.flip_v = false
    # See the note below about boolean assignment
    $AnimatedSprite.flip_h = velocity.x < 0
elif velocity.y != 0:
    $AnimatedSprite.animation = "up"
    $AnimatedSprite.flip_v = velocity.y > 0
if (velocity.x != 0)
{
    animatedSprite.Animation = "walk";
    animatedSprite.FlipV = false;
    // See the note below about boolean assignment
    animatedSprite.FlipH = velocity.x < 0;
}
else if (velocity.y != 0)
{
    animatedSprite.Animation = "up";
    animatedSprite.FlipV = velocity.y > 0;
}

Bemerkung

Die Boolean-Zuweisungen im obigen Code sind eine gängige Abkürzung für Programmierer. Betrachte diesen Code im Vergleich zu der verkürzten Boolean-Zuweisung oben:

if velocity.x < 0:
    $AnimatedSprite.flip_h = true
else:
    $AnimatedSprite.flip_h = false
if (velocity.x < 0)
{
    animatedSprite.FlipH = true;
}
else
{
    animatedSprite.FlipH = false;
}

Starte die Szene erneut und überprüfe, ob alle Animationen richtig in jeder der Richtungen.

Tipp

Ein allgemeiner Fehler ist hier, die Namen der Animationen falsch zu benennen. Die Animationsnamen in dem SpriteFrames Bereich müssen mit dem, was im Code geschrieben steht, übereinstimmen. Wenn du Animation "Walk" heißt, dann muss auch ein großgeschriebenes "W" im Code stehen.

Spiele die Szene noch einmal ab und überprüfe, ob die Animationen in jede Richtung korrekt sind. Wenn Du dir sicher bist, dass die Bewegung korrekt funktioniert, füge diese Zeile zu _ready() hinzu, damit der Spieler zu Beginn des Spiels ausgeblendet wird:

hide()
Hide();

Vorbereitung auf Kollisionen

Wir wollen, dass Player erkennt, wann er von einem Feind getroffen wird, aber wir haben uns noch keine Feinde gemacht! Das ist in Ordnung, denn wir werden Godots Signal-Funktionalität nutzen, damit es funktioniert.

Füge das Folgende oben im Skript nach extends Area2D hinzu:

signal hit
// Don't forget to rebuild the project so the editor knows about the new signal.

[Signal]
public delegate void Hit();

Dies definiert ein benutzerdefiniertes Signal namens "hit", das wir von unserem Spieler aussenden (send out) lassen, wenn es mit einem Gegner kollidiert. Wir werden `Area2D` verwenden, um die Kollision zu erkennen. Wähle den Node Player und klicke auf die Registerkarte "Node" neben der Registerkarte Inspektor, um die Liste der Signale zu sehen, die der Spieler ausgeben kann:

../../_images/player_signals.png

Beachte, dass unser benutzerdefiniertes "hit"-Signal auch da ist! Da unsere Feinde RigidBody2D Nodes sein werden, brauchen wir das Signal body_entered(body: Node); dieses wird ausgesendet, wenn ein Körper den Spieler berührt. Klicke auf "Verbinden..." und dann, im "Signal verbinden"-Fenster, wieder auf "Verbinden". Wir müssen keine dieser Einstellungen ändern - Godot erstellt automatisch eine Funktion im Skript des Spielers. Diese Funktion wird immer dann aufgerufen, wenn das Signal ausgelöst wird - sie handhabt das Signal.

../../_images/player_signal_connection.png

Beachte das grüne Symbol, was anzeigt, dass das Signal mit dieser Funktion verbunden ist. Füge diesen Code zu der Funktion hinzu:

func _on_Player_body_entered(body):
    hide()  # Player disappears after being hit.
    emit_signal("hit")
    $CollisionShape2D.set_deferred("disabled", true)
public void OnPlayerBodyEntered(PhysicsBody2D body)
{
    Hide(); // Player disappears after being hit.
    EmitSignal("Hit");
    GetNode<CollisionShape2D>("CollisionShape2D").SetDeferred("disabled", true);
}

Jedes Mal, wenn ein Feind den Spieler trifft, wird das Signal ausgesendet. Wir müssen die Kollision des Spielers deaktivieren, damit das Treffer-Signal nicht mehr als einmal ausgelöst wird.

Bemerkung

Deaktivierung der Collision Shape während der Kollisionsberechnung der Engine kann einen Fehler auslösen. Der Gebrauch von set_deferred() erlaubt es Godot, die Collision Shape zu einem sicheren Zeitpunkt zu deaktivieren.

Das letzte was wir tun müssen ist eine Funktion hinzuzufügen, die wir aufrufen können, um den Spieler beim erneuten Start des Spiels zurückzusetzen.

func start(pos):
    position = pos
    show()
    $CollisionShape2D.disabled = false
public void Start(Vector2 pos)
{
    Position = pos;
    Show();
    GetNode<CollisionShape2D>("CollisionShape2D").Disabled = false;
}

Feind-Szene

Jetzt ist es an der Zeit die Feinde zu erstellen, denen der Spieler ausweichen muss. Ihr Verhalten wird nicht allzu komplex sein: Gegner werden zufällig an den Rändern des Bildschirms erscheinen und sich in einer zufälligen Richtung in einer geraden Linie fortbewegen, um danach geradeaus weiterzugehen.

Wir werden dies in eine Gegner-Szene einbauen, die wir dann instanziieren um beliebig viele unabhängige Gegner im Spiel erstellen zu können.

Bemerkung

Siehe Instanziieren, um mehr über das Instanzieren zu erfahren.

Node einrichten

Klicken Sie auf Szene -> Neue Szene und fügen Sie die folgenden Nodes hinzu:

Vergiss nicht, die Unterobjekte so einzustellen, dass sie nicht ausgewählt werden können, wie du es bei der Player-Szene getan hast.

Stelle in den RigidBody2D Eigenschaften Gravity Scale auf 0, damit der Gegner nicht nach unten fällt. Klicke außerdem unter dem Abschnitt PhysicsBody2D auf die Eigenschaft Mask und deaktiviere das erste Kontrollkästchen. Dadurch wird sichergestellt, dass die Gegner nicht miteinander kollidieren.

../../_images/set_collision_mask.png

Richte das AnimatedSprite so wie wir es für den Spieler getan haben. Dieses Mal haben wir drei Animationen: fly, swim, und walk. Zu jeder Animation gibt es zwei Bilder im "art" Ordner.

Setze die "Geschwindigkeit (FPS)" auf 3 für alle Animationen.

../../_images/mob_animations.gif

Stelle die Eigenschaft Playing im Inspektor auf "On".

Wir werden eine der Animationen zufällig auswählen, sodass die Gegner eine gewisse Vielfalt haben.

Wie die Spieler-Bilder müssen auch diese Gegner-Bilder verkleinert werden. Setze die Scale Eigenschaft von AnimatedSprite auf (0.75, 0.75).

Wie in der Szene Player, füge eine CapsuleShape2D für die Kollision hinzu. Um die Form mit dem Bild auszurichten, musst Du die Eigenschaft Rotation Degrees auf 90 (unter Node2D-Transform im Inspektor) einstellen.

Speicher die Szene.

Feind-Skript

Füge ein Skript zum Gegner und folgende Member-Variablen hinzu:

extends RigidBody2D

export var min_speed = 150  # Minimum speed range.
export var max_speed = 250  # Maximum speed range.
public class Mob : RigidBody2D
{
    // Don't forget to rebuild the project so the editor knows about the new export variables.

    [Export]
    public int MinSpeed = 150; // Minimum speed range.

    [Export]
    public int MaxSpeed = 250; // Maximum speed range.

}

Wir wählen mit einem Zufallswert zwischen min_speed und max_speed, wie schnell sich jeder Gegner bewegen wird (es wäre trivial, würden sich alle mit der selben Geschwindigkeit fortbewegen). Stelle sie im Inspektor auf 150 und 250. Wir haben auch einen Array mit den Namen der drei Animationen, mit dessen Hilfe wir eine Zufällige auswählen können.

Nun lass uns den Rest des Skripts betrachten. In _ready() wählen wir zufällig einen der drei Animationstypen:

func _ready():
    var mob_types = $AnimatedSprite.frames.get_animation_names()
    $AnimatedSprite.animation = mob_types[randi() % mob_types.size()]
// C# doesn't implement GDScript's random methods, so we use 'System.Random' as an alternative.
static private Random _random = new Random();

public override void _Ready()
{
    var animSprite = GetNode<AnimatedSprite>("AnimatedSprite");
    var mobTypes = animSprite.Frames.GetAnimationNames();
    animSprite.Animation = mobTypes[_random.Next(0, mobTypes.Length)];
}

Zunächst werden wir die Liste der Animationsnamen von der Eigenschaft frames im AnimatedSprite erhalten. Diese gibt ein Array zuruück, dass alle drei Animationsnamen enthält: ["walk", "swim", "fly"].

Wir werden eine zufällige Zahl zwischen``0`` und``2`` auswählen müssen, um einen von diesen Namen aus der Liste auswählen zu können (Arrays starten bei 0). randi() % n wählt eine zufällige Ganzzahl zwischen 0 und n-1 aus.

Bemerkung

Du musst randomize() verwenden, wenn Du möchtest, dass Ihre Reihenfolge der "Zufallszahlen" bei jedem erneuten Ausführen der Szene unterschiedlich ist. Wir werden randomize() in unserer Main Szene verwenden, so dass wir es hier nicht brauchen. randi() % n ist der Standardweg, um eine zufällige ganze Zahl zwischen 0 und n-1 zu erhalten.

Das letzte Stück ist, die Mobs dazu zu bringen, sich selbst zu löschen, wenn sie den Bildschirm verlassen. Verbinde das Signal screen_exited() vom Node Visibility und füge diesen Code hinzu:

func _on_VisibilityNotifier2D_screen_exited():
    queue_free()
public void OnVisibilityNotifier2DScreenExited()
{
    QueueFree();
}

Damit ist die Szene des Gegners fertig.

Hauptszene

Jetzt ist es an der Zeit, alles zusammenzubringen. Erstelle eine neue Szene und füge einen Node genannt Main hinzu. Klicke auf die Schaltfläche "Instance" und wähle Deine gespeicherte Player.tscn.

../../_images/instance_scene.png

Füge nun die folgenden Nodes als untergeordnete Elemente von Main hinzu und benenne sie wie abgebildet (Werte sind in Sekunden):

  • Timer (genannt MobTimer) - um zu steuern, wie oft Gegner erscheinen
  • Timer (genannt ScoreTimer) - um die Punktzahl jede Sekunde zu erhöhen
  • Timer (genannt StartTimer) - um eine Verzögerung vor dem Start zu geben
  • Position2D (genannt StartPosition) - um die Startposition des Spielers anzuzeigen

Stelle die Eigenschaft Wait Time von jedem der Timer Nodes wie folgt ein:

  • MobTimer: 0.5
  • ScoreTimer: 1
  • StartTimer: 2

Stelle zusätzlich die Eigenschaft One Shot von StartTimer` auf "An" und stelle Position des Nodes StartPosition auf (240, 450).

Gegner (Mobs) erzeugen

Der Haupt-Node wird neue Gegner hervorbringen, und wir möchten, dass sie an einer beliebigen Stelle am Rande des Bildschirms erscheinen. Füge einen Path2D <class_Path2D>`Node namens ``MobPath` als Unterobjekt von Main hinzu. Wenn Du Path2D auswählst, siehst du oben im Editor einige neue Schaltflächen:

../../_images/path2d_buttons.png

Select the middle one ("Add Point") and draw the path by clicking to add the points at the corners shown. To have the points snap to the grid, make sure "Use Grid Snap" and "Use Snap" are both selected. These options can be found to the left of the "Lock" button, appearing as a magnet next to some dots and intersecting lines, respectively.

../../_images/grid_snap_button.png

Wichtig

Zeichne den Pfad im Uhrzeigersinn, sonst erscheinen deine Gegner nach außen statt nach innen!

../../_images/draw_path2d.gif

Nachdem Du den Punkt 4 im Bild platziert hast, klicke auf die Schaltfläche "Kurve schließen" und Deine Kurve ist vollständig.

Nachdem der Pfad definiert ist, füge einen PathFollow2D Node als Unterobjekt von MobPath hinzu und nenne ihn MobSpawnLocation. Dieser Node dreht sich automatisch und folgt dem Pfad, während er sich bewegt, so dass wir damit eine beliebige Position und Richtung entlang des Pfades auswählen können.

Die Szene sollte so aussehen:

../../_images/main_scene_nodes.png

Haupt-Skript

Füge ein Skript zu Main hinzu. Am Anfang des Skripts verwenden wir export (PackedScene), damit wir die Gegner-Szene auswählen können, die wir als Instanz verwenden wollen.

extends Node

export (PackedScene) var Mob
var score

func _ready():
    randomize()
public class Main : Node
{
    // Don't forget to rebuild the project so the editor knows about the new export variable.

    [Export]
    public PackedScene Mob;

    private int _score;

    // We use 'System.Random' as an alternative to GDScript's random methods.
    private Random _random = new Random();

    public override void _Ready()
    {
    }

    // We'll use this later because C# doesn't support GDScript's randi().
    private float RandRange(float min, float max)
    {
        return (float)_random.NextDouble() * (max - min) + min;
    }
}

Klicke auf die Main Node und man wird eine Gegner Eigenschaft im Inspector unter "Script Variables" erkennen.

Man kann einen Wert für eine Eigenschaft auf zwei Wege hinzufügen:

  • Ziehe Mob.tscn aus dem Panel "FileSystem" und lege es in der Eigenschaft Mob unter den Skript-Variablen des Nodes Main ab.
  • Klicke auf den unteren Pfeil neben "[empty]" und wähle "Load". Klicke auf Mob.tscn.

Wähle den „Player“-Node im Szenenbaum aus und klicke dann auf die "Node"-Registerkarte. Als nächstes, stelle sicher, dass "Signale" ausgewählt ist.

Du solltest eine Liste der Signale für die Player Node sehen. Suche und doppelklicke das hit Signal (oder Rechtsklicke darauf und drücke "Connect..."). Dies wird das Fenster für die Signal-Verbindung öffnen. Wir wollen eine neue Funktion namen game_over erstellen, welche angeben wird, was passieren muss, wenn das Spiel beendet wurde. Schreib "game_over" in die "Reciever Method"-Box unten im Fenster und klicke auf "Connect". Füge dann sowohl den folgenden Code zur neuen Funkion, als auch eine new_game-Funktion, die alles für ein neues Spiel einstellen wird:

func game_over():
    $ScoreTimer.stop()
    $MobTimer.stop()

func new_game():
    score = 0
    $Player.start($StartPosition.position)
    $StartTimer.start()
public void GameOver()
{
    GetNode<Timer>("MobTimer").Stop();
    GetNode<Timer>("ScoreTimer").Stop();
}

public void NewGame()
{
    _score = 0;

    var player = GetNode<Player>("Player");
    var startPosition = GetNode<Position2D>("StartPosition");
    player.Start(startPosition.Position);

    GetNode<Timer>("StartTimer").Start();
}

Verbinde nun das Signal Timeout() von jedem der Timer-Nodes (StartTimer, ScoreTimer ` und MobTimer) mit dem Main-Skript. StartTimer startet die anderen beiden Timer. ScoreTimer erhöht die Punktzahl um 1.

func _on_StartTimer_timeout():
    $MobTimer.start()
    $ScoreTimer.start()

func _on_ScoreTimer_timeout():
    score += 1
public void OnStartTimerTimeout()
{
    GetNode<Timer>("MobTimer").Start();
    GetNode<Timer>("ScoreTimer").Start();
}

public void OnScoreTimerTimeout()
{
    _score++;
}

In _on_MobTimer_timeout () erstellen wir eine Gegner-Instanz, wähle einen zufälligen Startpunkt entlang des Path2D aus und setze den Gegner in Bewegung. Der PathFollow2D-Node dreht sich automatisch, wenn er dem Pfad folgt. Wir verwenden diesen, um die Richtung des Gegners sowie dessen Position auszuwählen.

Beachte, dass der Szene mit add_child() eine neue Instanz hinzugefügt werden muss.

func _on_MobTimer_timeout():
    # Choose a random location on Path2D.
    $MobPath/MobSpawnLocation.offset = randi()
    # Create a Mob instance and add it to the scene.
    var mob = Mob.instance()
    add_child(mob)
    # Set the mob's direction perpendicular to the path direction.
    var direction = $MobPath/MobSpawnLocation.rotation + PI / 2
    # Set the mob's position to a random location.
    mob.position = $MobPath/MobSpawnLocation.position
    # Add some randomness to the direction.
    direction += rand_range(-PI / 4, PI / 4)
    mob.rotation = direction
    # Set the velocity (speed & direction).
    mob.linear_velocity = Vector2(rand_range(mob.min_speed, mob.max_speed), 0)
    mob.linear_velocity = mob.linear_velocity.rotated(direction)
public void OnMobTimerTimeout()
{
    // Choose a random location on Path2D.
    var mobSpawnLocation = GetNode<PathFollow2D>("MobPath/MobSpawnLocation");
    mobSpawnLocation.Offset = _random.Next();

    // Create a Mob instance and add it to the scene.
    var mobInstance = (RigidBody2D)Mob.Instance();
    AddChild(mobInstance);

    // Set the mob's direction perpendicular to the path direction.
    float direction = mobSpawnLocation.Rotation + Mathf.Pi / 2;

    // Set the mob's position to a random location.
    mobInstance.Position = mobSpawnLocation.Position;

    // Add some randomness to the direction.
    direction += GD.RandRange(-Mathf.Pi / 4, Mathf.Pi / 4);
    mobInstance.Rotation = direction;

    // Choose the velocity.
    mobInstance.LinearVelocity = new Vector2(GD.RandRange(150f, 250f), 0).Rotated(direction);
}

Wichtig

In Funktionen, die Winkel erfordern, verwendet GDScript Radien, nicht Grad. Wenn Du dich beim Arbeiten mit Grad wohler fühlst, musst Du die Funktionen deg2rad() und rad2deg() verwenden, um zwischen den beiden zu konvertieren.

Die Szene testen

Lass und die Szene testen, um sicherzustellen, dass alles funktioniert. Füge dies _ready() hinzu:

func _ready():
    randomize()
    new_game()
    public override void _Ready()
    {
        NewGame();
    }
}

Lass uns auch Main zu unserer "Hauptszene" hinzufügen - die automatisch läuft, wenn das Spiel startet. Drücke den "Start"-Knopf und wählen Main.tscn im Dialog.

Es sollte jetzt möglich sein den Spieler zu bewegen, erscheinende Gegner zu sehen, und den Spieler verschwinden zu sehen, wenn er von einem Gegner getroffen wird.

Wenn man sicher ist, dass alles läuft, lösche den Aufruf zum new_game() von _ready().

HUD

Das letzte Bauteil, das unser Spiel braucht, ist eine Benutzeroberfläche: eine Schnittstelle, um Dinge wie Punktestand, eine "Game Over"-Meldung und einen Neustart-Button anzuzeigen. Erstelle eine neue Szene und füge einen CanvasLayer Node namens HUD hinzu. "HUD" steht für "Heads-up Display", ein Informationsdisplay, das als Overlay über der Spielansicht erscheint.

Mit dem Node CanvasLayer können wir unsere Oberflächenelemente auf einer Ebene über dem Rest des Spiels zeichnen, so dass die angezeigten Informationen nicht durch irgendwelche Spielelemente wie den Spieler oder Gegner verdeckt werden.

Das HUD sollte die folgenden Informationen anzeigen:

  • Punktzahl, geändert durch ScoreTimer.
  • Eine Nachricht, wie z.B. "Game Over" oder "Get Ready!"
  • Eine "Start"-Taste, um das Spiel zu starten.

Das Basis-Node für UI-Elemente ist Control. Um unsere Benutzeroberfläche zu erstellen, verwenden wir zwei Arten vom Control Node: Label und Button.

Erstelle das Folgende als Unterobjekte des HUD-Nodes:

  • Label genannt ScoreLabel.
  • Label genannt MessageLabel.
  • Button genannt StartButton.
  • Timer genannt MessageTimer.

Klicke auf das ScoreLabel und gib eine Zahl in das _Text_field im Inspektor ein. Die Standardschriftart für Control-Nodes ist klein und lässt sich nicht gut skalieren. In den Spielassets ist eine Schriftdatei namens "Xolonium-Regular.ttf" enthalten. Um diese Schriftart zu verwenden, gehe für jeden der drei Control-Nodes wie folgt vor:

  1. Wähle unter "Custom Fonts" "Neues DynamicFont"
../../_images/custom_font1.png
  1. Klicke auf den von Dir hinzugefügten "DynamicFont" und wähle unter "Font/Font Data" "Lade" und wähle die Datei "Xolonium-Regular.ttf". Du musst auch die Schriftart-Größe einstellen. Eine Einstellung von 64 funktioniert gut.
../../_images/custom_font2.png

Sobald Sie das auf dem ScoreLabel erledigt haben, können Sie den Pfeil nach unten drücken neben der DynamicFont-Eigenschaft und wählen "Kopieren", dann "Einfügen" in den gleichen Ort bei den anderen zwei Kontroll-Nodes.

Bemerkung

Anker und Ränder: Control-Nodes haben eine Position und Größe, aber sie haben auch Anker und Ränder. Anker definieren den Ursprung - den Bezugspunkt für die Kanten des Nodes. Die Ränder werden automatisch aktualisiert, wenn Du einen Steuer-Node verschiebst oder in der Größe änderst. Sie stellen den Abstand von den Kanten des Steuer-Nodes zu seinem Anker dar. Siehe Benutzeroberflächen gestalten mit Kontroll-Nodes für weitere Details.

Ordne die Nodes wie unten gezeigt an. Klicke auf den "Layout"-Knopf, um das Layout eines Control-Nodes festzulegen:

../../_images/ui_anchor.png

Du kannst die Nodes manuell verschieben, um sie zu platzieren oder verwende die folgenden Einstellungen, für eine genauere Platzierung:

ScoreLabel (HighScore)

  • Layout : "vollständige Breite"
  • Text : 0
  • Align : "Mittig"

Nachricht

  • Layout : "HCenter Wide"
  • Text : Dodge the Creeps!
  • Align : "Mittig"
  • Autowrap : "An"

StartButton (Startknopf)

  • Text : Start
  • Layout : "Center Bottom"
  • Margin :
    • Top: -200
    • Bottom: -100

Stellen Sie im MessageTimer die Wartezeit auf 2` und stellen Sie die Einmalige Aufnahme Eigenschaft auf "Ein".

Füge nun dieses Skript zu HUD hinzu:

extends CanvasLayer

signal start_game
public class HUD : CanvasLayer
{
    // Don't forget to rebuild the project so the editor knows about the new signal.

    [Signal]
    public delegate void StartGame();
}

Das Signal start_game signalisiert dem Node Main, dass die Taste gedrückt wurde.

func show_message(text):
    $Message.text = text
    $Message.show()
    $MessageTimer.start()
public void ShowMessage(string text)
{
    var message = GetNode<Label>("Message");
    message.Text = text;
    message.Show();

    GetNode<Timer>("MessageTimer").Start();
}

Diese Funktion wird aufgerufen, wenn wir eine Nachricht vorübergehend anzeigen möchten, z.B. "Get Ready".

func show_game_over():
    show_message("Game Over")
    # Wait until the MessageTimer has counted down.
    yield($MessageTimer, "timeout")

    $Message.text = "Dodge the\nCreeps!"
    $Message.show()
    # Make a one-shot timer and wait for it to finish.
    yield(get_tree().create_timer(1), "timeout")
    $StartButton.show()
async public void ShowGameOver()
{
    ShowMessage("Game Over");

    var messageTimer = GetNode<Timer>("MessageTimer");
    await ToSignal(messageTimer, "timeout");

    var message = GetNode<Label>("Message");
    message.Text = "Dodge the\nCreeps!";
    message.Show();

    await ToSignal(GetTree().CreateTimer(1), "timeout");
    GetNode<Button>("StartButton").Show();
}

Diese Funktion wird aufgerufen, wenn der Spieler verliert. "Game Over" wird für 2 Sekunden angezeigt und wechselt dann zum Titelbildschirm. Nach einer kurzen Pause wird die "Start"-Schaltfläche angezeigt.

Bemerkung

Falls eine kurze Pause benötigt wird, kann als alternative zur Timer-Node die Funktion create_timer() von SceneTree verwendet werden. Das kann sehr nützlich sein, um eine Verzögerung hinzuzufügen. Wie im obigen Code, wo wir ein wenig warten wollen, bevor die "Start" Taste angezeigt wird.

func update_score(score):
    $ScoreLabel.text = str(score)
public void UpdateScore(int score)
{
    GetNode<Label>("ScoreLabel").Text = score.ToString();
}

Diese Funktion wird von Main aufgerufen, wenn sich der Punktestand ändert.

Verbinde jeweils das Signal timeout() von MessageTimer und das Signal pressed() von StartButton und füge den folgenden Code in die neuen Funktionen ein:

func _on_StartButton_pressed():
    $StartButton.hide()
    emit_signal("start_game")

func _on_MessageTimer_timeout():
    $Message.hide()
public void OnStartButtonPressed()
{
    GetNode<Button>("StartButton").Hide();
    EmitSignal("StartGame");
}

public void OnMessageTimerTimeout()
{
    GetNode<Label>("Message").Hide();
}

Verbinde das HUD mit Main

Jetzt, da wir die HUD-Szene erstellt haben, gehe zurück zu Main und platziere die HUD-Szene in Main genauso wie die Player-Szene. Der gesamte Szenenbaum sollte so aussehen, also stelle sicher, dass nichts fehlt:

../../_images/completed_main_scene.png

Nun müssen wir die HUD-Funktionalität mit unserem Main-Skript verbinden. Dies erfordert einige Ergänzungen in der Main-Szene:

In dem Node-Bereich, verbinde das Signal der HUD start_game zu der new_game() Funktion der Hauptnode durch Tippen von "new_game" in der "Receiver Methode" in dem "Verbinde ein Signal"-Fenster. Stelle sicher, dass das grüne Verbindungssymbol nun neben ``func new_game()``im Skript erscheint.

Aktualisiere in new_game() die Punkteanzeige und zeige die Meldung "Get Ready" an:

$HUD.update_score(score)
$HUD.show_message("Get Ready")
var hud = GetNode<HUD>("HUD");
hud.UpdateScore(_score);
hud.ShowMessage("Get Ready!");

In game_over() müssen wir die entsprechende HUD Funktion aufrufen:

$HUD.show_game_over()
GetNode<HUD>("HUD").ShowGameOver();

Abschließend füge nachfolgendes _on_ScoreTimer_timeout() hinzu, um die Anzeige mit den sich ändernden Punkten zu synchronisieren:

$HUD.update_score(score)
GetNode<HUD>("HUD").UpdateScore(_score);

Jetzt kannst du spielen! Klicke auf die Schaltfläche "Projekt abspielen (F5)". Du wirst aufgefordert, eine Hauptszene auszuwählen. Wähle dann Main.tscn aus.

Alten Gegner entfernen

Wenn du bis zum "Game Over" spielst und dann ein neues Spiel startest, befinden sich die Gegner des vorherigen Spiel noch auf dem Bildschirm. Es wäre besser, wenn diese beim Start eines neuen Spiels verschwinden würden. Wir benötigen einen Weg um alle Gegner zu entfernen und dies geschieht mit der "Gruppen" Funktion.

Wählen Sie in der Szene Mob den Wurzel-Node und klicken Sie auf die Registerkarte "Node" neben dem Inspektor (die gleiche Stelle, an der Sie die Signale des Nodes finden). Klicken Sie neben "Signale" auf "Gruppen", und Sie können einen neuen Gruppennamen eingeben und auf "Hinzufügen" klicken.

../../_images/group_tab.png

Nun werden alle Gegner in der Gruppe "Mobs" sein. Wir können dann die folgende Zeile zur Funktion game_over() in Main hinzufügen:

get_tree().call_group("mobs", "queue_free")
GetTree().CallGroup("mobs", "queue_free");

Die Funktion call_group() ruft die benannte Funktion auf jedem Node in einer Gruppe auf - in diesem Fall sagen wir jedem Gegner, dass er sich selbst löschen soll.

Fertigstellung

Wir haben jetzt alle Funktionen für unser Spiel fertiggestellt. Im Folgenden werden einige Schritte beschrieben, um etwas mehr „Tiefe“ hinzuzufügen, die Spielerlebnis verbessert. Zögere nicht, das Gameplay mit Deinen eigenen Ideen zu erweitern.

Hintergrund

Der voreingestellte graue Hintergrund ist nicht sehr ansprechend, ändern wir also die Farbe. Eine Möglichkeit, dies zu tun, ist die Verwendung eines ColorRect <class_ColorRect> -Nodes. Mache ihn zum ersten Node unter ``Main`, damit er hinter dem anderen Node gezeichnet wird. ColorRect hat nur eine Eigenschaft: Color. Wähle eine Farbe aus, die Dir gefällt, und ziehe die Größe des ColorRect so, dass es den Bildschirm abdeckt.

Wenn Du ein Hintergrundbild hast, kannst Du dies stattdessen mittels TextureRect Node hinzufügen.

Soundeffekte

Sound und Musik können effektive Mittel sein, das Spielerlebnis attraktiver zu gestalten. In Deinem Ordner mit den Spielressourcen befinden sich zwei Sounddateien: „House in a Forest Loop.ogg“ für Hintergrundmusik und „gameover.wav“ für den Fall, dass der Spieler verliert.

Füge zwei AudioStreamPlayer Nodes als Kinder von Main hinzu. Benenne einen davon Music und den anderen DeathSound. Klicke bei jedem Node auf die Eigenschaft Stream, klicke auf „Lade“ und wähle die entsprechende Audiodatei aus.

Um die Musik abzuspielen, füge $Music.play() in der new_game() Funktion und $Music.stop() in der game_over() Funktion hinzu.

Füge schließlich $DeathSound.play() in der game_over() Funktion hinzu.

Tastenkürzel

Da das Spiel mit Tastatur gesteuert wird, wäre es praktisch, wenn wir das Spiel auch durch Drücken einer Taste auf der Tastatur starten könnten. Eine Möglichkeit hierzu ist die Verwendung der „Shortcut“ -Eigenschaft des Button-Nodes.

Wähle in der HUD-Szene den StartButton aus, und suche im Inspektor nach seiner „Shortcut“-Eigenschaft. Wähle "Neues Shortcut" und klicke auf das „Shortcut“-Element. Eine zweite „Shortcut“-Eigenschaft wird angezeigt. Wähle „Neue InputEventAction“ und klicke auf das neue „InputEvent“-Element. Gib schließlich in der Eigenschaft „Action“ den Namen „ui_select“ ein. Dies ist das Standard Eingabe-Ereignis, das der Leertaste zugeordnet ist.

../../_images/start_button_shortcut.png

Wenn nun die Start-Schaltfläche angezeigt wird, kannst Du entweder darauf klicken oder die :kbd:`Leertaste ` drücken, um das Spiel zu starten.

Projektdateien

Eine vollständige Version dieses Projekts findest Du unter: