Das erste Spiel

Übersicht

Diese Anleitung wird Ihnen dabei helfen, Ihr erstes Godot-Projekt umzusetzen. Sie werden 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 Sie bereits etwas Programmiererfahrung haben. Falls Sie vorher noch nie programmiert haben, sollten Sie hier beginnen: Skripten.

Das Spiel heißt "Dodge the Creeps!". Ihr 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. Sie sollten sich deshalb an 2D halten, bis Sie ein gutes Verständnis für den Spielentwicklungsprozess haben.

Projektkonfiguration

Starten Sie Godot und erstellen ein neues Projekt. Danach, laden Sie dodge_assets.zip herunter. Die Datei beinhaltet die Bilder und Töne, sogenannte "Assets", die Sie verwenden werden um das Spiel zu erstellen. Entpacken Sie die Dateien in den zuvor gewählten Projektordner.

Bemerkung

In dieser Anleitung gehen wir davon aus, dass Sie mit dem Editor vertraut sind. Falls Die Szenen und Nodes noch nicht gelesen haben, informiere Sie sich darüber, wie Sie ein Projekt einrichten und den Editor verwenden können.

Dieses Spiel verwendet das Hochformat, daher müssen wir die Größe des Spielfensters anpassen. Klicken Sie auf Projekt -> Projekteinstellungen -> Anzeige -> Fenster und setzen "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 können Sie Ihre Szenen und Skripte jedoch im Wurzel-Ordner des Projekts speichern, der als res:// bezeichnet wird. Sie können Ihren Projektordner im Dateisystem-Dock in der unteren linken Ecke sehen:

../../_images/filesystem_dock.png

Spieler-Szene

The first scene will define the Player object. One of the benefits of creating a separate Player scene is that we can test it separately, even before we've created other parts of the game.

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. Klicken Sie auf den "Andere Node" - Knopf und fügen 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ählen Sie 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

Speichern Sie die Szene. Klicken Sie auf Szene -> Speichern oder drücken Ctrl + S unter Windows/Linux oder Cmd + S unter MacOS.

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 Stil-Richtlinien).
  • C#: Klassen, Exportvariablen und Methoden verwenden die PascalCase-Schreibweise, private Felder _camelCase, lokale Variablen und Parameter verwenden camelCase (Siehe C# Stil-Richtlinien). Beachten Sie die genaue Schreibweise, wenn Sie Signale einbinden möchten.

Sprite-Animation

Klicken Sie auf den Player Node und fügen ein AnimatedSprite Node als Unterobjekt hinzu. Das AnimatedSprite übernimmt das Erscheinungsbild und die Animationen für unseren Spieler. Achten Sie 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 suchen Sie die Eigenschaft Frames im Inspektor und klicken "<null>" -> "Neue SpriteFrames". Nun sollte sich automatisch das Animationspanel öffnen:

../../_images/spriteframes_panel.png

Auf der linken Seite befindet sich eine Liste von Animationen. Klicken Sie auf die "default" Animation und benennen sie in "walk" um. Dann klicken Sie auf die "hinzufügen" Schaltfläche, um eine zweite Animation "up" hinzuzufügen. Finden Sie die zwei Bilder, playerGrey_up[1/2] und playerGrey_walk[1/2], im "Dateisystem" Reiter und ziehen diese 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. Klicken Sie auf den AnimatedSprite Node und setzen die Eigenschaft Scale auf (0.5, 0.5). Sie können sie im Inspektor unterhalb der Überschrift ``Node2D``finden.

../../_images/player_scale.png

Abschließend fügen Sie 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. Klicken Sie im Inspektor neben "Shape" auf "<null>" -> "New CapsuleShape2D". Verwenden Sie die zwei Anfasser und verändere die Form so, dass sie das Sprite überdeckt:

../../_images/player_coll_shape.png

Wenn Sie fertig sind sollte Ihre 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. Klicken Sie auf den Player Node und dann auf die Schaltfläche "Skript hinzufügen":

../../_images/add_script_button.png

Im Fenster mit den Skripteinstellungen können Sie die Standardeinstellungen beibehalten. Klicken Sie einfach auf "Erstellen":

Bemerkung

Wenn Sie ein C#-Skript oder andere Sprachen verwenden wollen, wählen Sie die Sprache aus dem Auswahlmenü "Sprache", bevor Sie auf Erstellen klicken.

../../_images/attach_node_window.png

Bemerkung

Wenn dies Ihr erster Kontakt mit GDScript ist, lesen Sie bitte Skripten, bevor Sie fortfahren.

Beginnen Sie, indem Sie die Member-Variablen deklarieren, 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 Sie das Schlüsselwort export für die erste Variable speed verwenden, ermöglicht es Ihnen dessen Wert im Inspektor einzustellen. Dies kann für Werte nützlich sein, die Sie wie die integrierten Eigenschaften eines Nodes anpassen möchten. Klicken Sie auf den Node Player, und die Eigenschaft wird jetzt im Bereich "Skriptvariablen" des Inspektors angezeigt. Denken Sie daran, wenn Sie den Wert hier ändern, wird der im Skript verwendete Wert überschrieben.

Warnung

Wenn Sie C# verwenden, müssen Sie 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. Sie können 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.

Sie können herausfinden, ob eine Taste gedrückt wird, indem Sie Input.is_action_pressed() benutzen, 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 Sie beispielsweise rechts und runter gleichzeitig halten, 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 Sie noch nie zuvor Vektor-Mathematik verwendet haben oder eine Auffrischung benötigen, finden Sie 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.

Klicken Sie "Spiele Szene" (F6) und stellen 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 die Animation "Walk" heißt, dann muss auch ein großgeschriebenes "W" im Code stehen.

Spielen Sie die Szene noch einmal ab und überprüfen, ob die Animationen in jede Richtung korrekt sind. Wenn Sie sich sicher sind, dass die Bewegung korrekt funktioniert, fügen Sie 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ählen Sie den Node Player und klicken auf die Registerkarte "Node" neben der Registerkarte Inspektor, um die Liste der Signale zu sehen, die der Spieler ausgeben kann:

../../_images/player_signals.png

Beachten Sie, 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. Klicken Sie 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

Beachten Sie das grüne Symbol, was anzeigt, dass das Signal mit dieser Funktion verbunden ist. Fügen Sie 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:

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

Stellen Sie in den RigidBody2D Eigenschaften Gravity Scale auf 0, damit der Gegner nicht nach unten fällt. Klicken Sie außerdem unter dem Abschnitt PhysicsBody2D auf die Eigenschaft Mask und deaktivieren 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ügen Sie eine CapsuleShape2D für die Kollision hinzu. Um die Form mit dem Bild auszurichten, müssen Sie 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

Sie müssen randomize() verwenden, wenn Sie möchten, 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

Now it's time to bring it all together. Create a new scene and add a Node named Main. Ensure you create a Node, not a Node2D. Click the "Instance" button and select your saved 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ügen Sie einen Path2D <class_Path2D>`Node namens ``MobPath` als Unterobjekt von Main hinzu. Wenn Sie Path2D auswählen, sehen Sie oben im Editor einige neue Schaltflächen:

../../_images/path2d_buttons.png

Wählen Sie die Mittlere ("Punkt hinzufügen (in leerem Raum)"). Zeichnen Sie den Pfad, indem Sie durch klicken auf die dargestellten Ecken die Punkte hinzuzufügen. Damit die Punkte am Gitter einrasten, stellen Sie sicher, dass "Gitter-Einrasten benutzen(Shift+G)" aktiviert ist. Diese Option findet sich links vom "Schloss"-Symbol und wird als ein "Magnet neben sich schneidender Linien" dargestellt.

../../_images/grid_snap_button.png

Wichtig

Zeichnen Sie den Pfad im Uhrzeigersinn, sonst erscheinen Ihre Gegner nach außen statt nach innen!

../../_images/draw_path2d.gif

Nachdem Sie den Punkt 4 im Bild platziert haben, klicken Sie auf die Schaltfläche "Kurve schließen" und Ihre 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;
    }
}

Klicken Sie 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.
  • Klicken Sie auf den unteren Pfeil neben "[empty]" und wählen "Load". Klicken Sie auf Mob.tscn.

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

Sie sollten eine Liste der Signale für die Player Node sehen. Suchen und doppelklicken Sie das hit Signal (oder Rechtsklicke darauf und drücken "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. Schreiben Sie "game_over" in die "Reciever Method"-Box unten im Fenster und klicken auf "Connect". Fügen Sie dann sowohl den folgenden Code zur neuen Funktion, 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ählen Sie einen zufälligen Startpunkt entlang des Path2D aus und setzen 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 += RandRange(-Mathf.Pi / 4, Mathf.Pi / 4);
    mobInstance.Rotation = direction;

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

Wichtig

In Funktionen, die Winkel erfordern, verwendet GDScript Radien, nicht Grad. Wenn Sie sich beim Arbeiten mit Grad wohler fühlst, müssen Sie die Funktionen deg2rad() und rad2deg() verwenden, um zwischen 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();
    }
}

Lassen Sie uns auch Main zu unserer "Hauptszene" hinzufügen - die automatisch läuft, wenn das Spiel startet. Drücken Sie 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.

Klicken Sie auf das ScoreLabel und geben 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, gehen Sie für jeden der drei Control-Nodes wie folgt vor:

  1. Wählen Sie unter "Custom Fonts" "Neues DynamicFont"
../../_images/custom_font1.png
  1. Klicken Sie auf den von Ihnen hinzugefügten "DynamicFont" und wählen unter "Font/Font Data" "Lade" und wählen die Datei "Xolonium-Regular.ttf". Sie müssen 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 Sie einen Steuer-Node verschieben oder in der Größe ändern. 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.

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

../../_images/ui_anchor.png

Sie können die Nodes manuell verschieben um sie zu platzieren oder verwenden Sie 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 können Sie spielen! Klicken Sie auf die Schaltfläche "Projekt abspielen (F5)". Sie werden aufgefordert, eine Hauptszene auszuwählen. Wählen Sie dann Main.tscn aus.

Alten Gegner entfernen

Wenn Sie bis zum "Game Over" spielen und dann ein neues Spiel starten, 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ögern Sie nicht, das Gameplay mit Ihren 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. Machen Sie ihn zum ersten Node unter ``Main`, damit er hinter dem anderen Node gezeichnet wird. ColorRect hat nur eine Eigenschaft: Color. Wählen Sie eine Farbe aus die Ihnen gefällt und ziehe die Größe des ColorRect so, dass es den Bildschirm abdeckt.

Wenn Sie ein Hintergrundbild haben, können Sie dies stattdessen mittels TextureRect Node hinzufügen.

Soundeffekte

Sound und Musik können effektive Mittel sein, das Spielerlebnis attraktiver zu gestalten. In Ihrem 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ügen Sie zwei AudioStreamPlayer Nodes als Kinder von Main hinzu. Benennen Sie einen davon Music und den anderen DeathSound. Klicken Sie bei jedem Node auf die Eigenschaft Stream, klicken auf „Lade“ und wählen 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ählen Sie in der HUD-Szene den StartButton aus, und suchen im Inspektor nach seiner „Shortcut“-Eigenschaft. Wählen Sie "Neues Shortcut" und klicke auf das „Shortcut“-Element. Eine zweite „Shortcut“-Eigenschaft wird angezeigt. Wählen Sie „Neue InputEventAction“ und klicken auf das neue „InputEvent“-Element. Geben Sie 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, können Sie entweder darauf klicken oder die :kbd:`Leertaste ` drücken, um das Spiel zu starten.

Projektdateien

Eine vollständige Version dieses Projekts finden Sie unter: