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

Why 2D? 3D games are much more complex than 2D ones. You should stick to 2D until you have a good understanding of the game development process and how to use Godot.

Projektkonfiguration

Launch Godot and create a new project. Then, download dodge_assets.zip. This contains the images and sounds you’ll be using to make the game. Unzip these files in your project folder.

Bemerkung

For this tutorial, we will assume you are familiar with the Godot editor. If you haven’t read Szenen und Nodes, do so now for an explanation of setting up a project and using the editor.

This game is designed for portrait mode, so we need to adjust the size of the game window. Click on Project -> Project Settings -> Display -> Window and set „Width“ to 480 and „Height“ to 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 this project, we will make 3 independent scenes: Player, Mob, and HUD, which we will combine into the game’s Main scene. In a larger project, it might be useful to create folders to hold the various scenes and their scripts, but for this relatively small game, you can save your scenes and scripts in the project’s root folder, identified by res://. You can see your project folders in the FileSystem Dock in the lower left corner:

../../_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 eine Wurzel-Node für das Spieler-Objekt wählen. Als eine generelle Regel, eine 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 eine :ref:`Area2D <class_Area2D> Node zu der Szene hinzu.

../../_images/add_node.png

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

With Area2D we can detect objects that overlap or run into the player. Change the node’s name to Player by double-clicking on it. Now that we’ve set the scene’s root node, we can add additional nodes to give it more functionality.

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

Save the scene. Click Scene -> Save, or press Ctrl + S on Windows/Linux or Cmd + S on 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 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“. Click again to open the „SpriteFrames“ panel:

../../_images/spriteframes_panel.png

On the left is a list of animations. Click the „default“ one and rename it to „walk“. Then click the „New Animation“ button to create a second animation named „up“. Find the player images in the „FileSystem“ tab - they’re in the art folder you unzipped earlier. Drag the two images for each animation, named playerGrey_up[1/2] and playerGrey_walk[1/2], into the „Animation Frames“ side of the panel for the corresponding animation:

../../_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

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

Den Spieler Bewegen

Now we need to add some functionality that we can’t get from a built-in node, so we’ll add a script. Click the Player node and click the „Attach Script“ button:

../../_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().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.

You can detect whether a key is pressed using Input.is_action_pressed(), which returns true if it’s pressed or false if it isn’t.

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();
    }
}

We start by setting the velocity to (0, 0) - by default, the player should not be moving. Then we check each input and add/subtract from the velocity to obtain a total direction. For example, if you hold right and down at the same time, the resulting velocity vector will be (1, 1). In this case, since we’re adding a horizontal and a vertical movement, the player would move faster diagonally than if it just moved horizontally.

We can prevent that if we normalize the velocity, which means we set its length to 1, then multiply by the desired speed. This means no more fast diagonal movement.

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.

We also check whether the player is moving so we can call play() or stop() on the AnimatedSprite.

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

Now that we have a movement direction, we can update the player’s position. We can also use clamp() to prevent it from leaving the screen. Clamping a value means restricting it to a given range. Add the following to the bottom of the _process function (make sure it’s not indented under the else):

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

this likely means you spelled the name of the AnimatedSprite node wrong. Node names are case-sensitive and $NodeName must match the name you see in the scene tree.

Animationen

Now that the player can move, we need to change which animation the AnimatedSprite is playing based on its direction. We have the „walk“ animation, which shows the player walking to the right. This animation should be flipped horizontally using the flip_h property for left movement. We also have the „up“ animation, which should be flipped vertically with flip_v for downward movement. Let’s place this code at the end of the _process() function:

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

The boolean assignments in the code above are a common shorthand for programmers. Since we’re doing a comparison test (boolean) and also assigning a boolean value, we can do both at the same time. Consider this code versus the one-line boolean assignment above:

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.

When you’re sure the movement is working correctly, add this line to _ready(), so the player will be hidden when the game starts:

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.

Add the following at the top of the script, after extends Area2D:

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

Notice our custom „hit“ signal is there as well! Since our enemies are going to be RigidBody2D nodes, we want the body_entered(body: Node) signal. This signal will be emitted when a body contacts the player. Click „Connect..“ and the „Connect a Signal“ window appears. We don’t need to change any of these settings so click „Connect“ again. Godot will automatically create a function in your player’s script.

../../_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

Disabling the area’s collision shape can cause an error if it happens in the middle of the engine’s collision processing. Using set_deferred() tells Godot to wait to disable the shape until it’s safe to do so.

The last piece is to add a function we can call to reset the player when starting a new game.

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

Feind-Szene

Now it’s time to make the enemies our player will have to dodge. Their behavior will not be very complex: mobs will spawn randomly at the edges of the screen, choose a random direction, and move in a straight line.

We’ll create a Mob scene, which we can then instance to create any number of independent mobs in the game.

Bemerkung

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

Node einrichten

Click Scene -> New Scene and add the following nodes:

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

Set up the AnimatedSprite like you did for the player. This time, we have 3 animations: fly, swim, and walk. There are two images for each animation in the art folder.

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

../../_images/mob_animations.gif

Set the Playing property in the Inspector to “On”.

Wir werden eine der Animationen zufällig auswählen, sodass die mobs eine gewisse Variabilität haben.

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

As in the Player scene, add a CapsuleShape2D for the collision. To align the shape with the image, you’ll need to set the Rotation Degrees property to 90 (under „Transform“ in the Inspector).

Save the scene.

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

}

When we spawn a mob, we’ll pick a random value between min_speed and max_speed for how fast each mob will move (it would be boring if they were all moving at the same speed).

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

You must use randomize() if you want your sequence of „random“ numbers to be different every time you run the scene. We’re going to use randomize() in our Main scene, so we won’t need it here.

The last piece is to make the mobs delete themselves when they leave the screen. Connect the screen_exited() signal of the VisibilityNotifier2D node and add this code:

func _on_VisibilityNotifier2D_screen_exited():
    queue_free()
public void OnVisibilityNotifier2DScreenExited()
{
    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

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 die Mittlere („Punkt hinzufügen (in leerem Raum)“). Zeichne den Pfad, indem durch klicken auf die dargestellten Ecken, die Punkte hinzuzufügst. Damit die Punkte am Gitter einrasten, stelle 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

Zeichne den Pfad in Uhrzeigersinn-Reihenfolge, sonst spawnen deine Mobs 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.

Your scene should look like this:

../../_images/main_scene_nodes.png

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;
    }
}

Klicke auf die Main Node und man wird eine Mob Eigenschaft im Inspector unter „Script Variables“ erkennen.

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

  • Drag Mob.tscn from the „FileSystem“ panel and drop it in the Mob property .
  • Klicke auf den unteren Pfeil neben „[empty]“ und wähle „Load“. Klicke auf Mob.tscn.

Next, click on the Player and connect the hit signal. We want to make a new function named game_over, which will handle what needs to happen when a game ends. Type „game_over“ in the „Receiver Method“ box at the bottom of the „Connect a Signal“ window and click „Connect“. Add the following code to the new function, as well as a new_game function that will set everything up for a new game:

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();
}

Now connect the timeout() signal of each of the Timer nodes (StartTimer, ScoreTimer , and MobTimer) to the main script. StartTimer will start the other two timers. ScoreTimer will increment the score by 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.

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

Why PI? In functions requiring angles, GDScript uses radians, not degrees. If you’re more comfortable working with degrees, you’ll need to use the deg2rad() and rad2deg() functions to convert between the two.

Testing the scene

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, die automatisch läuft, wenn das Spiel startet. Drücke den „Start“-Knopf und wähle Main.tscn im Dialog.

Es sollte jetzt möglich sein, den Spieler zu bewegen, siehe die erscheinenden mobs, und sehe, wie der Spieler verschwindet, wenn er einen mob erwischt.

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

The HUD needs to display the following information:

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

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:

  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 Gestaltung von Benutzeroberflächen mit den 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“

Message

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

StartButton (Startknopf)

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

On the MessageTimer, set the Wait Time to 2 and set the One Shot property to „On“.

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();
}

This function is called when we want to display a message temporarily, such as „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

When you need to pause for a brief time, an alternative to using a Timer node is to use the SceneTree’s create_timer() function. This can be very useful to add delays such as in the above code, where we want to wait some time before showing the „Start“ button.

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.

Connect the timeout() signal of MessageTimer and the pressed() signal of StartButton and add the following code to the new functions:

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

Now that we’re done creating the HUD scene, go back to Main. Instance the HUD scene in Main like you did the Player scene. The scene tree should look like this, so make sure you didn’t miss anything:

../../_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.

Entfernen von alten Monstern

If you play until „Game Over“ and then start a new game right away, the creeps from the previous game may still be on the screen. It would be better if they all disappeared at the start of a new game. We just need a way to tell all the mobs to remove themselves. We can do this with the „group“ feature.

Wählen Sie in der Szene Mob den Wurzelknoten und klicken Sie auf die Registerkarte „Knoten“ neben dem Inspektor (die gleiche Stelle, an der Sie die Signale des Knotens 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 Mobs 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 Knoten in einer Gruppe auf - in diesem Fall sagen wir jedem Mob, 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

The default gray background is not very appealing, so let’s change its color. One way to do this is to use a ColorRect node. Make it the first node under Main so that it will be drawn behind the other nodes. ColorRect only has one property: Color. Choose a color you like and select „Layout“ -> „Full Rect“ so that it covers the screen.

You could also add a background image, if you have one, by using a TextureRect node instead.

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.

Keyboard shortcut

Since the game is played with keyboard controls, it would be convenient if we could also start the game by pressing a key on the keyboard. We can do this with the „Shortcut“ property of the Button node.

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 „InputEventAction“. 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

Now when the start button appears, you can either click it or press Space to start the game.

Projektdateien

Eine vollständige Version dieses Projekts findest Du unter: