Dein erstes Spiel

Übersicht

Dieses Tutorial wird Dich dabei anleiten, 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: Scripting.

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

Für dieses Tutorial 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.

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-Panel 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

Für den Anfang, klicke auf „Hinzufügen/Neuen Node erstellen“ und füge einen Area2D Node zur Szene hinzu.

../../_images/add_node.png

Mit Area2D können Objekte erkannt werden, die den Spieler überlappen oder an diesen berühren. Ändere den Namen des Nodes zu Player, indem Du auf dessen Namen klickst. Dieser Node ist der Wurzel-Node der Szene. Man kann weitere Nodes und damit weitere Funktionalität hinzufügen.

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

Click on the Player node and add an AnimatedSprite node as a child. The AnimatedSprite will handle the appearance and animations for our player. Notice that there is a warning symbol next to the node. An AnimatedSprite requires a SpriteFrames resource, which is a list of the animations it can display. To create one, find the Frames property in the Inspector and click „[empty]“ -> „New SpriteFrames“. This should automatically open the SpriteFrames panel.

../../_images/spriteframes_panel.png

Auf der linken Seite befindet sich die Liste der Animationen. Klicke auf die „default“ Animation und benenne sie in „right“ um. Dann klicke auf die „hinzufügen“ Schaltfläche, um eine zweite Animation, „up“ genannt, hinzuzufügen. Ziehe die zwei Bilder der Animation, playerGrey_up[1/2] und playerGrey_walk[1/2], in den „Animationframes“ Bereich des Fensters:

../../_images/spriteframes_panel2.png

Das 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

Den Spieler Bewegen

Jetzt müssen wir einige Funktionen 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 Scripting, 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().GetSize();
}

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, das true zurückgibt, wenn sie gedrückt wird oder 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, wenn wir die Geschwindigkeit normalisieren, was bedeutet, dass wir ihre Länge auf 1 festlegen und mit der gewünschten Geschwindigkeit multiplizieren. 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 Vector math. Es ist gut zu wissen, wird aber für den Rest dieses Tutorials nicht notwendig sein.

Wir prüfen auch, ob sich der Spieler bewegt, damit wir die AnimatedSprite-Animation starten oder stoppen können.

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.

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

Jetzt, da wir eine Bewegungsrichtung haben, können wir die Position des Player``s aktualisieren und mit ``clamp() verhindern, dass er den Bildschirm verlässt, indem wir am Ende der _process Funktion 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

Clamping eines Wertes bedeutet, ihn auf einen bestimmten Bereich zu beschränken.

Klicke auf „Szene starten“ (F6) und bestätige, dass Du den Player auf dem Bildschirm in alle Richtungen bewegen kannst. Die Konsolenausgabe, die beim Abspielen der Szene geöffnet wird, kann durch Anklicken von „Ausgabe“ (die blau hervorgehoben werden sollte) unten links im Bedienfeld geschlossen werden.

Warnung

Wenn Du im „Debugger“-Fenster einen Fehler erhältst, der sich auf eine „null instance“ bezieht, bedeutet das wahrscheinlich, dass Du den Node-Namen falsch geschrieben hast. Node-Namen sind case-sensitive und $NodeName oder get_node("NodeName") müssen mit dem Namen übereinstimmen, den Du im Szenenbaum siehst.

Animationen

Jetzt, da sich der Spieler bewegen kann, müssen wir ändern, welche Animation der AnimatedSprite je nach Richtung abspielt. Wir haben eine „right“ 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 = "right"
    $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 = "right";
    // See the note below about boolean assignment
    animatedSprite.FlipH = velocity.x < 0;
    animatedSprite.FlipV = false;
}
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

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( Object body ); 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.

Tipp

Wenn Sie ein Signal verbinden, kannst Du, anstatt dass Godot eine Funktion für Dich erstellt, auch den Namen einer bestehenden Funktion angeben, mit der Du das Signal verbinden möchtest.

Füge diesen Code zur 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

Deaktivieren 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 Stück für unseren Spieler ist es, eine Funktion hinzuzufügen, die wir aufrufen können, um den Spieler beim Start eines neuen 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, sich die Feinde zu machen, denen unser Spieler ausweichen muss. Ihr Verhalten wird nicht sehr komplex sein: Mobs werden zufällig an den Rändern des Bildschirms spawnen und sich in eine zufällige Richtung in einer geraden Linie bewegen, dann verschwinden , wenn sie aus dem Bildschirm gehen.

Wir werden dies in eine Mob Szene einbauen, die wir dann instanzieren um beliebig viele unabhängige Mobs im Spiel erstellen können.

Node einrichten

Klicken Sie auf Szene -> Neue Szene und wir erstellen den Mob.

Die Mob-Szene verwendet die folgenden Nodes:

  • RigidBody2D (genannt Mob)
    • AnimatedSprite
    • CollisionShape2D
    • VisibilityNotifier2D (genannt Visibility)

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 Mob 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 Mobs nicht miteinander kollidieren.

../../_images/set_collision_mask.png

Richte das AnimatedSprite wie für den Spieler ein. Diesmal haben wir 3 Animationen: fly, swim, und walk. Stelle die Eigenschaft Playing im Inspektor auf „On“ und passe die Einstellung „Speed (FPS)“ wie unten gezeigt an. Wir werden eine dieser Animationen nach dem Zufallsprinzip auswählen, damit die Mobs eine gewisse Vielfalt haben.

../../_images/mob_animations.gif

fly sollte auf 3 FPS eingestellt werden, während swim und walk` auf 4 FPS eingestellt werden.

Wie die Spieler-Bilder müssen auch diese Mob-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 einstellen.

Feind-Skript

Füge ein Skript zum Mob hinzu und füge die folgenden Member-Variablen hinzu:

extends RigidBody2D

export var min_speed = 150  # Minimum speed range.
export var max_speed = 250  # Maximum speed range.
var mob_types = ["walk", "swim", "fly"]
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.

    private String[] _mobTypes = {"walk", "swim", "fly"};
}

Wir wählen mit einem Zufallswert zwischen min_speed und max_speed, wie schnell sich jeder Mob bewegen wird (es wäre langweilig, wenn sie sich alle mit der gleichen Geschwindigkeit bewegen würden). Stelle sie im Inspektor auf 150 und 250. Wir haben auch ein Array mit den Namen der drei Animationen, mit dem 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():
    $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()
{
    GetNode<AnimatedSprite>("AnimatedSprite").Animation = _mobTypes[_random.Next(0, _mobTypes.Length)];
}

Bemerkung

Du musst randomize() verwenden, wenn Du möchtest, dass Ihre Reihenfolge der „Zufallszahlen“ bei jedem 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_Visibility_screen_exited():
    queue_free()
public void OnVisibilityScreenExited()
{
    QueueFree();
}

Damit ist die Szene des Mob 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

Bemerkung

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

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

  • Timer (genannt MobTimer) - um zu steuern, wie oft Mobs spawnen
  • 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).

Mobs spawnen

Der Haupt-Node wird neue Mobs 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

Wähle den mittleren („Punkt hinzufügen“) und zeichne, durch klicken, den Pfad um die Punkte an den dargestellten Ecken hinzuzufügen. Damit die Punkte am Gitter einrasten, stelle sicher, dass „Am Gitter einrasten“ aktiviert ist. Diese Option findest Du unter der Schaltfläche „Einrasteinstellungen“ links neben der „Schloss“-Schaltfläche , die als eine Reihe von drei vertikalen Punkten erscheint.

../../_images/draw_path2d.gif

Wichtig

Zeichne den Pfad in Uhrzeigersinn-Reihenfolge, sonst spawnen deine Mobs nach außen statt nach innen!

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.

Main Skript

Füge ein Skript zu Main hinzu. Am Anfang des Skripts verwenden wir export (PackedScene), damit wir die Mob-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;
    }
}

Ziehe Mob.tscn aus dem Panel „FileSystem“ und lege es in der Eigenschaft Mob unter den Script Variables des Nodes Main ab.

Klicke anschließend auf den Player und verbinde das Signal „Hit“. Wir wollen eine neue Funktion namens Game_over erstellen, die sich darum kümmert, was passieren muss, wenn ein Spiel endet. Gebe „game_over“ in das Feld „Method In Node“ unten im Fenster „Signal verbinden“ ein. Füge den folgenden Code sowie eine new_game Funktion hinzu, um alles für ein neues Spiel einzurichten:

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 Mob-Instanz, wähle einen zufälligen Startpunkt entlang des ``Path2D aus und setze den Mob in Bewegung. Der PathFollow2D-Node dreht sich automatisch, wenn er dem Pfad folgt. Wir verwenden diesen, um die Richtung des Mobs sowie dessen Position auszuwählen.

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

Klicke nun auf MobTimer im Szenenfenster, dann gehe zum Inspektorfenster, wechsel zur Nose-Ansicht, klicke dann auf Timeout() und verbinde das Signal.

Füge den folgenden Code hinzu:

func _on_MobTimer_timeout():
    # Choose a random location on Path2D.
    $MobPath/MobSpawnLocation.set_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.SetOffset(_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 += RandRange(-Mathf.Pi / 4, Mathf.Pi / 4);
    mobInstance.Rotation = direction;

    // Choose the velocity.
    mobInstance.SetLinearVelocity(new Vector2(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.

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 Mobs verdeckt werden.

Das HUD zeigt die folgenden Informationen an:

  • 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.

Click on the ScoreLabel and type a number into the Text field in the Inspector. The default font for Control nodes is small and doesn’t scale well. There is a font file included in the game assets called „Xolonium-Regular.ttf“. To use this font, do the following for each of the three Control nodes:

  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

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 Gestaltung von Benutzeroberflächen mit den Kontroll-Nodes für weitere Details.

Ordne die Nodes wie unten gezeigt an. Klicke auf die Schaltfläche „Anchor“, um den Anker 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)

  • Text : 0
  • Layout : „Top Wide“
  • Align : „Center“

MessageLabel (Nachrichten)

  • Text : Dodge the Creeps!
  • Layout : „HCenter Wide“
  • Align : „Center“

StartButton (Startknopf)

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

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):
    $MessageLabel.text = text
    $MessageLabel.show()
    $MessageTimer.start()
public void ShowMessage(string text)
{
    var messageLabel = GetNode<Label>("MessageLabel");
    messageLabel.Text = text;
    messageLabel.Show();

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

Diese Funktion wird aufgerufen, wenn wir eine Nachricht vorübergehend anzeigen möchten, z.B. „Get Ready“. Stellen Sie im MessageTimer die Wartezeit auf 2` und stellen Sie die Einmalige Aufnahme Eigenschaft auf „Ein“.

func show_game_over():
    show_message("Game Over")
    yield($MessageTimer, "timeout")
    $MessageLabel.text = "Dodge the\nCreeps!"
    $MessageLabel.show()
    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 messageLabel = GetNode<Label>("MessageLabel");
    messageLabel.Text = "Dodge the\nCreeps!";
    messageLabel.Show();

    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

Wenn Du eine Verzögerung benötigst, kannst Du als Timer-Node die Funktion create_timer() von SceneTree verwenden. Dies kann sehr nützlich sein, um eine Verzögerung auszulösen, z. B. im obigen Code, wo wir ein wenig warten müssen, bevor die Schaltfläche „Start“ 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 das Signal timeout() von MessageTimer mit dem Signal pressed() von StartButton.

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

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

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

Verbinde das HUD mit Main

Jetzt, da wir die HUD-Szene erstellt haben, speichere sie und gehe zurück zu Main. Platziere die HUD-Szene in Main genauso wie die Player-Szene und platziere sie unten im Baum. Der gesamte Baum sollte so aussehen, also stelle sicher, dass das 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:

Verbinden Sie auf der Registerkarte Node das Signal start_game des HUD mit der Funktion new_game().

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.

Entfernen von alten Monstern

Wenn du bis zum „Game Over“ spielst und dann ein neues Spiel startest, befinden sich die Monster des vorherigen Spiel noch auf dem Bildschirm. Es wäre besser, wenn diese beim Start eines neuen Spiels verschwinden würden.

Wir werden das Signal start_game benutzen, das bereits vom Knoten HUD ausgesendet wird, um die übrigen Gegner zu entfernen. Wir können in diesem Fall nicht den Editor dazu verwenden, das Signal mit den Gegnern zu verknüpfen, weil die Mob Knoten erst dann in der Szene Main erscheinen, wenn wir das Spiel starten. Stattdessen benutzen wir Code.

Fange damit an, die eine neue Funktion zu Mob.gd hinzuzufügen. queue_free() wird den aktuellen Knoten am Ende des aktuellen Frames löschen.

func _on_start_game():
    queue_free()
public void OnStartGame()
{
    QueueFree();
}

Dann füge eine neue Zeile in Main.gd Am Ende der _on_MobTimer_timeout() Funktion ein.

$HUD.connect("start_game", mob, "_on_start_game")
GetNode("HUD").Connect("StartGame", mobInstance, "OnStartGame");

Diese Zeile sagt dem neuen Mob-Knoten (auf den die Variable mob verweist), dass er auf jedes start_game-Signal vom HUD-Knoten reagieren soll, indem der die _on_start_game()-Funktion aktiviert.

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 auch einen Sprite-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 Tastatursteuerungen gespielt 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.

In the HUD scene, select the StartButton and find its Shortcut property in the Inspector. Select „New Shortcut“ and click on the „Shortcut“ item. A second Shortcut property will appear. Select „New InputEventAction“ and click the new „InputEvent“. Finally, in the Action property, type the name ui_select. This is the default input event associated with the spacebar.

../../_images/start_button_shortcut.png

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

Projektdateien

Eine vollständige Version dieses Projekts findest Du unter: