Twoja pierwsza gra

Przegląd

Ten samouczek pomoże ci stworzyć pierwszy projekt w Godot. Dowiesz się, jak działa edytor Godot, jak ustawić strukturę projektu i jak zbudować grę 2D.

Informacja

Projekt ten jest wstępem do obsługi silnika Godot. Zakłada on, że masz już pewne doświadczenie w programowaniu. Jeśli jesteś początkującym programistą, powinieneś zacząć od: Skrypty.

Gra nazywa się "Dodge the Creeps!" Twoja postać musi się poruszać i unikać wrogów jak najdłużej. Poniżej znajduje się końcowy efekt:

../../_images/dodge_preview.gif

Dlaczego 2D? Gry 3D są znacznie bardziej złożone niż 2D. Powinieneś trzymać się 2D, dopóki dobrze nie zrozumiesz procesu tworzenia gry i tego, jak działa silnik Godot.

Konfiguracja projektu

Uruchom Godota i stwórz nowy projekt. Następnie pobierz dodge_assets.zip. Archiwum to zawiera pliki graficzne i dźwiękowe, których będziesz używać do tworzenia gry. Rozpakuj te pliki do folderu projektu.

Informacja

W tym samouczku zakładamy, że znasz edytor Godot. Jeśli nie przeczytałeś Sceny i węzły, zrób to teraz, aby uzyskać wyjaśnienie dotyczące konfiguracji projektu i korzystania z edytora.

Ta gra będzie korzystać z trybu portretowego (pionowego), więc musimy dostosować rozmiar okna gry. Kliknij Projekt -> Ustawienia projektu -> Wyświetlanie -> Okno i ustaw "Szerokość" na 480 oraz "Wysokość" na 720.

Również w tej sekcji, przy opcjach "Rozciągaj", ustaw Tryb na "2d" i Aspekt na "keep" (zachowaj). Zapewni nam to, że gra będzie konsystentnie skalować się na ekranach o różnych rozmiarach.

Organizacja projektu

W tym projekcie powstaną 3 niezależne sceny: Player, Mob i HUD, które połączymy w główną scenę Main. W większym projekcie użytecznym może być istnienie różnych folderów do przechowywania różnych scen i ich skryptów, ale w tej stosunkowo małej grze zapiszemy sceny i skrypty w folderze głównym zwanym res://. Foldery projektów są widoczne w panelu systemu plików w lewym górnym rogu:

../../_images/filesystem_dock.png

Scena Player (Gracz)

Pierwszą sceną, którą stworzymy, będzie Player. Jedną z zalet stworzenia oddzielnej sceny Player jest to, że możemy ją przetestować oddzielnie, nawet zanim utworzymy inne części gry.

Struktura węzłów

Na początek musimy wybrać węzeł główny obiektu gracza. Zazwyczaj węzeł główny sceny powinien odzwierciedlać pożądaną funkcjonalność obiektu - to, czym obiekt jest. Kliknij przycisk "Inny węzeł" i dodaj węzeł Area2D na scenę.

../../_images/add_node.png

Godot wyświetli ikonę ostrzeżenia obok dodanego w drzewie sceny węzła. Zignorujmy to na tę chwilę. Zajmiemy się tym później.

Za pomocą Area2D możemy wykrywać obiekty, które zachodzą na siebie lub wpływają na gracza. Zmieńmy jego nazwę na Player klikając dwukrotnie na nazwę węzła. Jest to główny węzeł sceny i możemy dodać do niego dodatkowe węzły, aby zwiększyć jego funkcjonalność.

Przed dodaniem dzieci do węzła Player chcemy się upewnić, że nie przesunęliśmy ich przypadkowo lub nie zmieniliśmy ich wielkości klikając na nie. Zaznacz węzeł i kliknij ikonę po prawej stronie kłódki; jej podpowiedź brzmi "Zablokuj selekcję węzłów podrzędnych."

../../_images/lock_children.png

Zapisz scenę. Kliknij Scena -> Zapisz lub naciśnij Ctrl+S w Windowsie/Linuksie lub Command+S na systemie Mac.

Informacja

Dla tego projektu, będziemy korzystać z konwencji nazewnictwa typowych dla Godota.

  • GDScript: W klasach (węzłach) używać będziemy PascalCase, w zmiennych i funkcjach snake_case, a w stałych ALL_CAPS. Wszystkie nazwy zapisywane będą bez znaków diakrytycznych (Więcej o GDScript style guide).
  • C#: W klasach, eksportowanych zmiennych i metodach używać będziemy PascalCase, w polach prywatnych _camelCase, natomiast w zmiennych lokalnych i parametrach korzystać będziemy z camelCase (Więcej o C# style guide). Zwróć szczególną uwagę na nazwy metod przy łączeniu sygnałów.

Animacja węzła Sprite

Kliknij na węzeł Player i dodaj węzeł AnimatedSprite <class_AnimatedSprite>`jako dziecko. ``AnimatedSprite` będzie obsługiwać wygląd i animacje naszego gracza. Zauważ, że obok węzła znajduje się ikona ostrzeżenia. AnimatedSprite wymaga zasobu SpriteFrames, który jest listą animacji, które może wyświetlić. Aby go utworzyć, odszukaj właściwość Frames w inspektorze i kliknij "[pusty]" -> "Nowy SpriteFrames". Następnie kliknij na stworzony przed chwilą "SpriteFrames", aby otworzyć panel animacji:

../../_images/spriteframes_panel.png

Po lewej stronie znajduje się lista animacji. Kliknij "default" i zmień jego nazwę na "walk". Następnie kliknij przycisk "Nowa animacja", aby utworzyć drugą animację o nazwie "up". Znajdź grafiki gracza w panelu systemu plików - znajdują się one w folderze art, który wypakowaliśmy. Przeciągnij dwie grafiki dla każdej animacji o nazwach playerGrey_up[1/2] i playerGrey_walk[1/2] do części panelu podpisanej jako "Klatki animacji":

../../_images/spriteframes_panel2.png

Grafiki gracza są zbyt duże, aby zmieściły się w oknie gry, więc musimy je pomniejszyć. Kliknij na węzeł AnimatedSprite i ustaw właściwość Scale na (0.5, 0.5). W inspektorze możemy ją znaleźć pod nagłówkiem Node2D.

../../_images/player_scale.png

I wreszcie dodaj ColisionShape2D jako dziecko Player. W ten sposób zostaną wyznaczone granice obszaru kolizji gracza. Węzeł CapsuleShape2D najlepiej pasuje do tego węzła, więc obok "Shape" w inspektorze kliknij "[pusty]" -> "New CapsuleShape2D". Zmień rozmiar obszaru w celu okrycia całego sprite:

../../_images/player_coll_shape.png

Po zakończeniu, scena Player powinna wyglądać w ten sposób:

../../_images/player_scene_nodes.png

Pamiętaj o ponownym zapisaniu sceny po tych zmianach.

Poruszanie graczem

Teraz musimy dodać funkcjonalność, której nie możemy uzyskać z wbudowanego węzła, stąd wykorzystamy do tego skrypt. Kliknij na węzeł Player, a następnie kliknij przycisk "Dołącz skrypt":

../../_images/add_script_button.png

W oknie ustawień skryptu możesz pozostawić ustawienia domyślne. Kliknij na "Utwórz":

Informacja

Jeśli tworzysz skrypt w języku C# lub innym, wybierz odpowiedni język z rozwijanego menu Język przed naciśnięciem przycisku Utwórz.

../../_images/attach_node_window.png

Informacja

Jeśli po raz pierwszy spotykasz się z GDScriptem, przeczytaj Skrypty zanim przejdziesz dalej.

Zacznij od zadeklarowania zmiennych, których ten obiekt będzie potrzebował:

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

Użycie słowa kluczowego export przy pierwszej zmiennej speed pozwala na ustawienie jej wartości w inspektorze. Może to być przydatne w przypadku wartości, które mają być dostosowywane w taki sam sposób, jak wbudowane właściwości węzła. Kliknij na węzeł Player, w którym pokazała się nowa właściwość w sekcji "Script Variables" w inspektorze. Pamiętaj, że jeśli zmienisz znajdującą się tu wartość, nadpisze ona wartość podaną w skrypcie.

Ostrzeżenie

Jeżeli używasz C#m musisz (prze)budować projekt, jeśli chcesz zobaczyć nowowyeksportowane zmienne czy sygnały. Takie przebudowanie może zostać uruchomione ręcznie - w tym celu otwórz Panel Mono klikając na tekst "Mono", znajdujący się na dole okna edytora, a następnie kliknij przycisk "Zbuduj projekt".

../../_images/export_variable.png

Funkcja _ready() jest wywoływana, gdy węzeł wchodzi do drzewa sceny, co jest dobrym momentem, aby ustalić rozmiar okna gry:

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

Teraz możemy użyć funkcji _process() do zdefiniowania, co gracz będzie robił. _process() jest wywoływany co klatkę, więc użyjemy go do aktualizacji elementów naszej gry, które, jak się spodziewamy, będą się często zmieniać. W przypadku naszego gracza, musimy zrobić, co następuje:

  • Sprawdzić dane wejściowe.
  • Przesunąć gracza w określonym kierunku.
  • Odtworzyć odpowiednią animację.

Po pierwsze musimy sprawdzić dane wejściowe - dowiedzieć się, czy gracz naciska przycisk. W tej grze mamy do sprawdzenia 4 kierunki. Działania wejściowe są zdefiniowane w ustawieniach projektu w sekcji "Input Map". Można definiować własne zdarzenia i przypisać do nich różne klawisze, zdarzenia myszy lub inne wejścia. W tym demie będziemy używać domyślnych zdarzeń, które są przypisane do klawiszy strzałek na klawiaturze.

Za pomocą Input.is_action_pressed() możesz sprawdzić, czy klawisz jest wciśnięty. Naciśnięty klawisz zwraca true. Wartość false zostanie zwrócona, gdy klawisz nie jest wciśnięty.

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

Zaczniemy od ustawienia velocity na (0, 0) - domyślnie gracz nie powinien się poruszać. Sprawdzamy każde wejście i dodajemy/odejmujemy od velocity``(prędkość), aby uzyskać kierunek całkowity. Na przykład, jeśli przytrzymasz jednocześnie ``prawo i dół`, otrzymany wektor velocity będzie ``(1, 1) ``. W tym przypadku, ponieważ dodajemy ruch poziomy i pionowy, gracz poruszałby się szybciej niż gdyby po prostu poruszał się poziomo.

Możemy tego uniknąć, jeśli znormalizujemy prędkość, co oznacza ustawienie jej length (długości) na 1 i pomnożymy ją przez pożądaną prędkość. Oznacza to rezygnację z szybszego ruchu po przekątnej.

Wskazówka

Jeśli nigdy wcześniej nie używałeś matematyki wektorowej lub potrzebujesz przypomnienia, możesz zobaczyć wyjaśnienie użycia wektorów w Godocie (Wektory). Ogólnie dobrze jest to wiedzieć, jednak wiedza ta nie będzie konieczna dla reszty tego poradnika.

Sprawdzamy również, czy gracz porusza się, abyśmy mogli uruchomić (play()) bądź zatrzymać (stop()) animację AnimatedSprite.

$ jest skrótem od get_node(). Tak więc w kodzie powyżej, $AnimatedSprite.play() jest tym samym, co get_node("AnimatedSprite").play().

Wskazówka

W GDScrpt``$`` zwraca węzeł o ścieżce względnej dla aktuanego węzła lub null jeśli węzeł nie został znaleziony. Ponieważ AnimatedSprite jest dzieckiem bieżącego węzła, możemy użyć $AnimatedSprite.

Teraz, gdy mamy kierunek ruchu, możemy zaktualizować położenie gracza. Możemy również użyć clamp(), aby zapobiec opuszczenia ekranu przez naszą postać. Klampowanie wartości oznacza ograniczenie jej do danego zakresu. Dodaj na dole funkcji _process podany fragment kodu (upewnij się przy tym, że nie zostanie on wciągnięty pod 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)
);

Wskazówka

Parametr delta w funkcji _process() odnosi się do długości ramki - czasu, jaki zajęło wykonanie poprzedniej ramki. Użycie tej wartości zapewnia, że ruch pozostanie spójny nawet w przypadku zmiany ilości klatek.

Kliknij "Odtwórz scenę" (F6) i potwierdź, że możesz poruszać graczem po ekranie we wszystkich kierunkach.

Ostrzeżenie

Jeśli otrzymasz błąd w panelu "Debugger", mówiący

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

prawdopodobnie oznacza to, że nazwa AnimatedSprite została błędnie wpisana. W nazwach węzłów wielkość liter ma znaczenie i $NodeName lub get_node("NodeName") musi odpowiadać nazwie widocznej w drzewie sceny.

Wybieranie animacji

Teraz, kiedy nasz gracz może się już poruszać, musimy ustalić, która grafika animacji AnimatedSprite powinna być odtwarzana w zależności od wybranego kierunku. Mamy animację "walk"(chodzenie), która powinna być obracana poziomo za pomocą właściwości flip_h dla ruchu lewostronnego, oraz animację "up", która powinna być obracana pionowo za pomocą flip_v dla ruchu w dół. Umieśćmy ten kod na końcu naszej funkcji _process():

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

Informacja

Przypisania logiczne w powyższym kodzie są częstym skrótem dla programistów. Ponieważ robimy test porównawczy (boolean), a także przypisanie wartości logicznej boolean, możemy wykonać obie te czynności w tym samym czasie. Rozważmy pokazany tu kod w porównaniu do powyższego jednowierszowego przypisania logicznego:

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

Odtwórz scenę ponownie i sprawdź, czy wszystkie animacje są odtwarzane poprawnie w każdym z kierunków.

Wskazówka

Częstym błędem jest tu podanie niepoprawnych nazw animacji. Nazwy animacji w panelu SpriteFrames muszą odpowiadać nazwom wpisanym w kodzie. Jeśli nazywamy animację "Walk", musimy również użyć nazwy pisanej z wielkiej litery "W" w kodzie.

Gdy upewnisz się, że mechanika poruszania się działa poprawnie, dodaj pokazaną linijkę do _ready(), aby ukryć naszego gracza przy starcie gry:

hide()
Hide();

Przygotowanie do kolizji

Chcemy, aby Player wykrywał, kiedy zostanie uderzony przez wroga, jednak nie mamy jeszcze żadnych wrogów! Nie ma się jednak co tym przejmować, ponieważ korzystać będziemy z Godotowej funkcji sygnału (signal).

W górnej części skryptu, po extends Area2d, dodajmy następujący kod:

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

[Signal]
public delegate void Hit();

Definiuje on niestandardowy sygnał zwany "hit"(trafieniem), który nasz gracz wyemituje (wyśle), kiedy zderzy się z wrogiem. Do wykrywania kolizji użyjemy Area2D. Wybierzmy węzeł Player i kliknijmy zakładkę "Węzeł" obok zakładki Inspektor, aby zobaczyć listę sygnałów, które nasz gracz może emitować:

../../_images/player_signals.png

Zauważ, że nasz niestandardowy sygnał "hit" również się tutaj znajduje! Ponieważ nasi wrogowie będą węzłami RigidBody2D, chcemy sygnału body_entered(body: Node); będzie on emitowany, gdy ciało zetknie się z graczem. Kliknijmy przycisk "Połącz...", by otworzyć okno "Połącz sygnał do metody". Nie ma potrzeby zmieniać tu żadnego z domyślnych ustawień - po prostu ponownie kliknijmy "Połącz", a Godot automatycznie utworzy w skrypcie gracza funkcję _on_Player_body_entered.

../../_images/player_signal_connection.png

Należy zwrócić uwagę na zielony symbol oznaczający, że do funkcji podłączony jest sygnał. Dodajmy do stworzonej przed chwilą funkcji ten kod:

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

Za każdym razem, gdy wróg uderzy w gracza, wysłany zostanie sygnał. Po tym zdarzeniu musimy wyłączyć kolizje gracza, aby nie wywołać więcej niż jednego sygnału hit.

Informacja

Wyłączenie obszaru kształtu kolizji może spowodować błąd, jeśli nastąpi to w środku procesu przetwarzania kolizji przez silnik. Użycie ``set_deferred()```powie Godotowi, aby czekał na wyłączenie kształtu do momentu, aż bezpiecznie będzie można to zrobić.

Ostatnim elementem naszego gracza jest dodanie funkcji, którą możemy wywołać, aby go zresetować podczas uruchomienia nowej gry.

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

Scena wroga

Nadszedł czas, aby stworzyć przeciwników, których nasz gracz będzie musiał unikać. Ich zachowanie nie będzie zbyt skomplikowane: przeciwnicy będą pojawiać się losowo na krawędziach ekranu i poruszać w losowym kierunku po linii prostej, a następnie usuwać się, gdy "wyjdą" poza granicę ekranu po przeciwnej stronie.

Stworzymy scenę ``Mob``(Przeciwnik), której będziemy mogli tworzyć instancje, czyli powielać ją dowolną razy, tworząc niezależnych przeciwników w grze.

Informacja

Przeczytaj Tworzenie instancji, aby dowiedzieć się więcej o instancjach.

Konfiguracja węzła

Kliknijmy Scena -> Nowa scena i stwórzmy podane węzły:

Nie zapomnij ustawić dzieci tak, aby nie mogły być wybierane, tak jak zrobiliśmy to w przypadku sceny z graczem.

We właściwościach RigidBody2D ustawmy Gravity Scale``(Skala Grawitacji) na ``0, tak aby przeciwnik nie spadł w dół. Ponadto w sekcji ``PhysicsBody2D``(Ciało fizyczne 2D) kliknijmy właściwość ``Mask``(Maska) i odznaczmy pierwsze pole wyboru. Zapewni to, że przeciwnicy nie będą się zderzać.

../../_images/set_collision_mask.png

Skonfigurujmy AnimatedSprite tak, jak zrobiliśmy to dla gracza. Tym razem mamy 3 animacje: fly, swim i walk. W folderze "art" mamy po dwie grafiki dla każdej z animacji.

Ustawmy "Prędkość (FPS)" na 3 dla każdej z animacji.

../../_images/mob_animations.gif

W inspektorze ustawmy wartość właściwości Playing na "Włącz".

Będziemy wybrać jedną z tych animacji losowo, stąd nasi przeciwnicy będą w jakiś sposób różnorodni.

Podobnie, jak grafiki gracza, tak i grafiki przeciwników wymagają zmniejszenia. W inspektorze ustawmy skalę AnimatedSprite na (0,75, 0,75) (Scale znajduje się pod Node 2D w zakładce Transform).

Podobnie jak w przypadku sceny Player, dodaj CapsuleShape2D dla kolizji. Aby wyrównać kształt kolizji z grafiką przeciwnika, należy ustawić właściwość Rotation Degrees``(Kąt obrotu) na ``90 pod Node2D.

Zapisz scenę.

Skrypt wroga

Dołącz nowy skrypt do Mob i dodaj do niego następujące zmienne:

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.

}

Przy pojawieniu się (spawnie) wybierać będziemy losową wartość pomiędzy min_speed a max_speed dla każdego poruszającego się przeciwnika (w końcu byłoby nudno, gdyby wszyscy poruszały się tak samo).

Spójrzmy teraz na resztę skryptu. W _ready() losowo wybierajmy jeden z trzech typów animacji:

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

Na początek potrzebujemy listy nazw animacji z właściwości frames AnimatedSprite. Otrzymujemy tablicę zawierającą wszystkie trzy nazwy animacji: ["walk", "swim", "fly"].

Następnie uzyskujemy losową wartość pomiędzy 0 a 2, aby na jej podstawie móc uzyskać nazwę znajdującą się na wylosowanym miejscu na liście (tablica zaczyna liczenie miejsc od 0 - to znaczy, że na naszej liście nazwa walk kryje się pod wartością 0, nazwa swim pod 1, zaś nazwa fly pod 2). Funkcja randi() % n wybiera losową liczbę pomiędzy zerem, a n-1.

Informacja

Należy użyć randomize(), jeśli chcemy, aby sekwencja liczb "losowych" różniła się za każdym razem, gdy scena zostanie uruchomiona. Jednak z racji, że skorzystamy z randomize() w naszej scenie Main, nie jest to nam tutaj potrzebne.

Ostatnim krokiem jest zmuszenie przeciwników do samodzielnego usunięcia się po opuszczeniu ekranu. Trzeba podłączyć funkcję screen_exited() z sygnałem Visibility i dodać ten kod:

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

To kończy tworzenie sceny Mob.

Scena główna

Nadszedł czas, aby zebrać wszystkie stworzone przez nas elementy w całość. Utwórz nową scenę i dodaj Node <class_Node>`(węzeł) o nazwie ``Main`. Kliknij przycisk "Dodaj instancję" i wybierz zapisany Player.tscn.

../../_images/instance_scene.png

Następnie dodaj następujące węzły jako dzieci sceny Main i nazwij je tak, jak pokazano na rysunku (wartości są podane w sekundach):

  • Timer (o nazwie MobTimer) - czasomierz do kontroli częstotliwości tworzenia przeciwników
  • Timer (o nazwie ScoreTimer) - czasomierz mający co sekundę zwiększać liczbę zdobytych punktów
  • Timer (o nazwie StartTimer) - czasomierz służący podaniu opóźnienia przed rozpoczęciem
  • Position2D (o nazwie StartPosition) - wyznacznik pozycji startowej gracza

Set the Wait Time property of each of the Timer nodes as follows:

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

Dodatkowo, ustawmy właściwość One Shot w StartTimer na "Włącz" i ustaw właściwość Position z węzła StartPosition na (240, 450) (znajduje się pod Transform w Inspektorze).

Tworzenie przeciwników

Węzeł główny Main odpowiadał będzie za tworzenie nowych przeciwników. Chcemy, aby pojawiali się oni w losowych miejscach na krawędzi ekranu. Dodajmy węzeł Path2D o nazwie MobPath jako dziecko Main. Po wybraniu Path2D na górze edytora pojawi się kilka nowych przycisków:

../../_images/path2d_buttons.png

Wybierzmy środkowy z nich ("Dodaj Punkt") i narysujmy ścieżkę - kliknijmy w każdy z czterech rogów, aby dodawać w nich punkty. Aby punkty były wyrównane do siatki, zaznaczona musi być opcja "Użyj przyciągania do siatki". Opcję tę można znaleźć na lewo przycisku "Opcje przyciągania", którego ikonka to trzy pionowo ułożone kropki.

../../_images/grid_snap_button.png

Ważne

Narysuj ścieżkę zgodnie z ruchem zegara, albo twoi przeciwnicy będą tworzyli się na zewnątrz zamiast do wewnątrz!

../../_images/draw_path2d.gif

Po umieszczeniu punktu 4, kliknijmy przycisk "Zamknij krzywą", a krzywa zostanie ukończona.

Teraz, gdy ścieżka jest zdefiniowana, dodaj węzeł PathFollow2D <class_PathFollow2D>`jako dziecko ``MobPath` i nazwij go MobSpawnLocation. Węzeł ten będzie automatycznie obracał się i podążał ścieżką, tak abyśmy mogli go wykorzystać do wybrania losowego położenia i kierunku wzdłuż ścieżki.

Your scene should look like this:

../../_images/main_scene_nodes.png

Main script

Add a script to Main. At the top of the script, we use export (PackedScene) to allow us to choose the Mob scene we want to instance.

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

Click the Main node and you will see the Mob property in the Inspector under "Script Variables".

You can assign this property's value in two ways:

  • Drag Mob.tscn from the "FileSystem" panel and drop it in the Mob property .
  • Click the down arrow next to "[empty]" and choose "Load". Select Mob.tscn.

Next, select the Player node in the Scene dock, and access the Node dock on the sidebar. Make sure to have the Signals tab selected in the Node dock.

You should see a list of the signals for the Player node. Find and double-click the hit signal in the list (or right-click it and select "Connect..."). This will open the signal connection dialog. 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 signal connection dialog 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(), we will create a mob instance, pick a random starting location along the Path2D, and set the mob in motion. The PathFollow2D node will automatically rotate as it follows the path, so we will use that to select the mob's direction as well as its position.

Nowa instancja musi być dodana do sceny za pomocą add_child().

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

Ważne

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

Let's test the scene to make sure everything is working. Add this to _ready():

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

Let's also assign Main as our "Main Scene" - the one that runs automatically when the game launches. Press the "Play" button and select Main.tscn when prompted.

You should be able to move the player around, see mobs spawning, and see the player disappear when hit by a mob.

When you're sure everything is working, remove the call to new_game() from _ready().

HUD

Ostatnim elementem, którego potrzebuje nasza gra jest interfejs użytkownika: interfejs do wyświetlania takich informacji, jak wynik, komunikat "game over"(koniec gry) i przycisk restartu. Utwórz nową scenę i dodaj węzeł CanvasLayer o nazwie HUD. "HUD" oznacza "Heads-up display" - wyświetlacz informacyjny, który pojawia się jako nakładka na widoku gry i informuje o np. liczbie punktów, punktów życia.

Węzeł CanvasLayer pozwala nam narysować nasze elementy interfejsu użytkownika na warstwie będącą przed resztą gry, tak aby wyświetlane przez niego informacje nie były zakryte żadnymi elementami gry, takimi jak gracz lub przeciwnik.

The HUD needs to display the following information:

  • Wynik, zmieniany przez ScoreTimer.
  • Wiadomości takie jak "Koniec Gry" lub "Przygotuj Się!"
  • Przycisk "Start" rozpoczyna grę.

Podstawowym węzłem elementów interfejsu użytkownika jest Control. Aby utworzyć nasz interfejs użytkownika, będziemy używać dwóch typów węzła ref:Control <class_Control> - Label oraz Button.

Utwórz je jako dzieci węzła HUD:

  • Label nazwany ScoreLabel.
  • Label named Message.
  • Przycisk Button o nazwie StartButton.
  • Timer o nazwie 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:

  1. W zakładce "Czcionki niestandardowe" wybierz opcję "Nowa czcionka dynamiczna"
../../_images/custom_font1.png
  1. Click on the "DynamicFont" you added, and under "Font/Font Data", choose "Load" and select the "Xolonium-Regular.ttf" file. You must also set the font's Size. A setting of 64 works well.
../../_images/custom_font2.png

Once you've done this on the ScoreLabel, you can click the down arrow next to the DynamicFont property and choose "Copy", then "Paste" it in the same place on the other two Control nodes.

Informacja

Kotwice i marginesy: Węzły Control mają położenie i wielkość, ale także kotwice i margiesy. Kotwice określają początek - punkt odniesienia dla krawędzi węzła. Marginesy są aktualizowane automatycznie po przesunięciu lub zmianie rozmiaru węzła control. Przedstawiają one odległość od krawędzi węzła control do jego punktu zakotwiczenia. Więcej informacji na ten temat można znaleźć w temacie Twórz interfejs z węzłami kontrolującymi.

Arrange the nodes as shown below. Click the "Layout" button to set a Control node's layout:

../../_images/ui_anchor.png

Węzły można przeciągnąć ręcznie lub, aby uzyskać bardziej precyzyjne rozmieszczenie, należy użyć następujących ustawień:

ScoreLabel

  • Layout : "Top Wide"
  • Text : 0
  • Align : "Center"

Message

  • Layout : "HCenter Wide"
  • Text : Dodge the Creeps!
  • Align : "Center"
  • Autowrap : "On"

StartButton

  • Text : Start
  • Layout : "Center Bottom"
  • Margin :
    • Góra: -200
    • Dół: -100

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

Teraz dodaj ten skrypt do HUD:

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

Sygnał start_game informuje węzeł Main o naciśnięciu przycisku.

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

This function is called when the player loses. It will show "Game Over" for 2 seconds, then return to the title screen and, after a brief pause, show the "Start" button.

Informacja

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

This function is called by Main whenever the score changes.

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

Podłączenie HUD do 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

Teraz musimy podłączyć funkcję HUD do naszego skryptu Main. Wymaga to paru poprawek do sceny Main:

In the Node tab, connect the HUD's start_game signal to the new_game() function of the Main node by typing "new_game" in the "Receiver Method" in the "Connect a Signal" window. Verify that the green connection icon now appears next to func new_game() in the script.

W new_game(), zaktualizuj wyświetlany wynik i pokaż komunikat "Przygotuj się":

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

W game_over() musimy wywołać odpowiednią funkcję w HUD:

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

Na koniec dodaj to do on_on_ScoreTimer_timeout(), aby wyświetlacz był zsynchronizowany ze zmieniającym się wynikiem:

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

Teraz jesteś gotowy do gry! Kliknąć przycisk "Uruchom projekt". Zostaniesz poproszony o wybranie głównej sceny, więc wybierz Main.tscn.

Removing old creeps

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.

In the Mob scene, select the root node and click the "Node" tab next to the Inspector (the same place where you find the node's signals). Next to "Signals", click "Groups" and you can type a new group name and click "Add".

../../_images/group_tab.png

Now all mobs will be in the "mobs" group. We can then add the following line to the game_over() function in Main:

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

The call_group() function calls the named function on every node in a group - in this case we are telling every mob to delete itself.

Finishing up

Ukończyliśmy już wszystkie funkcjonalności naszej gry. Poniżej przedstawiamy kilka kroków, które należy jeszcze podjąć, aby dodać trochę więcej funkcji w celu poprawy wrażeń z gry. Zapraszamy do rozszerzenia rozgrywki o własne pomysły.

Tło

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.

Sound effects

Dźwięk i muzyka mogą być najskuteczniejszym sposobem na uatrakcyjnienie wrażeń z gry. W folderze zasobów do gry znajdują się dwa pliki dźwiękowe: "House In a Forest Loop.ogg" na muzykę w tle i "gameover.wav" odtwarzaną w razie przegranej.

Dodaj dwa węzły AudioStreamPlayer jako dzieci Main. Nazwij jedną z nich muzykę, a drugą DeathSound. Na każdym z nich kliknij na właściwość Stream, wybierz opcję "Wczytaj" i wybierz odpowiedni plik audio.

Aby odtwarzać muzykę, dodaj $Music.play() w funkcji new_game() i $Music.stop( ) w funkcji game_over().

Na koniec dodaj $DeathSound.play() do funkcji game_over().

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.

Project files

You can find a completed version of this project at these locations: