Kategorie
CrabbyBird

CrabbyBird #3 Generowanie świata gry – cześć I

Dzisiejszy wpis jest czwartym odcinkiem z serii, w której w ramach nauki Rusta i Godota pisze sobie klon FlappyBirda. Poprzednim razem zajmowaliśmy się ruchem kamery. Dziś rozpoczniemy generowanie świata gry, a dokładniej – ziemi. Gotowy kod z tego wpisu można znaleźć na githubie.

Dzisiejszy cel

Chcę, żeby świat gry był ograniczony od dołu ziemią, a kraboptak poruszał się względem niej. Jest to o tyle skomplikowane wyzwanie, że ma on sprawiać wrażenie nieskończonego. Osiągniemy to poprzez konstruowania ziemi z "kafelków", które będą dodawane i usuwane w zależności od tego, co widzi kamera w danej chwili.

Najpierw w edytorze Godota

Przygotujemy scenę Base w edytorze Godota. Użyjemy w niej wierzchołka StaticBody2D, żeby stanowiła dla kraboptaka nieruchoma przeszkodę.

Do dzieci tego wierzchołka dodajemy Sprite, w którym będzie obrazek ziemi dodany do katalogu assets. Dodajemy go w inspektorze w polu Texture. Odznaczamy pole Centered, tak aby pozycja obrazu była aktualizowana względem lewego górnego rogu okna gry.

Dodajmy też wierzchołek CollisionShape2D, ustawiamy jego pozycję i szerokość tak, żeby pokrywał obrazek.

Właściwie to chcemy, żeby pozycja kafelka była wyznaczana względem lewego dolnego rogu, dlatego w inspektorze wierzchołka Base (StaticBody2D), w zakładce Transform proponuję ustawić y na wysokość obrazka. U mnie jest to -112. Wymiary obrazka można sprawdzić klikając na obrazek tekstury w wierzchołku Sprite. O tutaj:

W ten sposób otrzymaliśmy "kafelek" ziemi. Co dalej?
Chcemy teraz przygotowane w ten sposób kafelki ustawiać jeden obok drugiego, żeby wypełniły cały widoczny obszar. Kafelki, które wyjdą poza widoczność kamery będziemy usuwać. Tak otrzymamy efekt nieskończonej mapy.

Menadżer kafelków

Najpierw zajmiemy się generowaniem ziemi. Ponieważ problem ten będzie wymagał informacji, co widzi kamera, decyzja o usuwaniu lub dodawaniu elementów powinna zapadać w strukturze World. Potrzebujemy tam nowej struktury przetrzymującej aktualnie widoczne elementy i umiejącej nimi zarządzać. Nazwijmy ją BaseManager.

W edytorze Godota tworzymy nową scenę. Ja swoją nazwałam BaseManager. Będzie to wierzchołek typu Node2D. W inspektorze w zakładce transform ustawiłam y na wysokość ekranu (u mnie 720px) po to, żeby w dalszej części pozycję dodawanych kafelków ustawiać względem dolnej krawędzi ekranu. Do tak powstałego elementu tworzymy NativeScript. W polu klasy wpisujemy nazwę klasy, która ma mu odpowiadać (u mnie BaseManager). Przepis jak to zrobić krok po kroku możesz znaleźć w pierwszej części tej serii.

To na razie tyle w Godocie, teraz pora przejść do Rusta. Ogólnie pomysł jest taki: manager wie jak stworzyć kafelek ziemi, kiedy i gdzie ma go dodać. Zajmijmy się teraz po kolei jak go tego wszystkiego nauczyć. W pliku base_manager.rs stwórzmy strukturkę BaseManager:

#[derive(NativeClass)]
#[inherit(Node2D)]
pub struct BaseManager {
    base_template: Option<Ref<PackedScene>>,
    sprite_height: f32,
    sprite_width: f32,
    end_position: f32,
}

Dzięki polu base_template manager wie, jak stworzyć kafelek. Pola sprite_height i sprite_width przydadzą się przy umieszczaniu kafelków, a end_position pozwoli stwierdzić czy juz pora dodawać nowy. Ale może przeanalizujmy to po kolei.

Tworzenie nowych kafelków

Po pierwsze zajmiemy się tym, jak stworzyć kafelek. Przepis na kafelki opisaliśmy przed chwilą w Godocie tworząc scenę Base. Do stosowania przygotowanej sceny jako wzorca służy struktura PackedScene. Jest to to uproszczony interfejs do sceny spakowanej w pliku .tscn. W ten sposób wczytujemy całą jej strukturę tak, jak zdefiniowaliśmy ją w edytorze Godota, wraz z zależnościami i ustawieniami.

W funkcji new ustawiamy początkowe wartości wszystkich pól. Zwróćmy uwagę, że base_template ustawiamy na None. W funkcji _ready zajmujemy się ustawieniem właściwej wartości pola base_template. Wymiary kafelka to wymiary obrazka, pole end_position określa, gdzie kończy się wygenerowana ziemia. Jako, że jeszcze nie dodaliśmy żadnego kafelka, ustawmy ją na 0.

pub fn new(_owner: &Node2D) -> BaseManager {
        BaseManager {
            base_template: None,
            sprite_height: 112.,
            sprite_width: 336.,
            end_position: 0.,
        }
    }

#[export]
fn _ready(&mut self, _owner: &Node2D) {
    self.base_template = ResourceLoader::godot_singleton()
        .load("res://scenes/Base.tscn", "", false)
        .and_then(|scene| {
            unsafe { scene.assume_safe().cast::<PackedScene>() }.map(|x| x.claim())
        });

    match &self.base_template {
        Some(_scene) => godot_print!("Loaded child scene successfully!"),
        None => godot_print!("Could not load child scene. Check name."),
    }
}

Aby dostać się do zasobów stworzonych w edytorze, potrzebujemy ResourceLoadera.

Funkcji load dajemy trzy argumenty:

  • ścieżkę do sceny,
  • type_hint, czyli typ zasobu. Jest to argument dla Godota, nie powoduje on żadnego przekształcenia w Ruscie. W GDScripcie ten argument jest domyślny i nie musimy go podawać.
  • zmienną logiczną określającą czy zapisywać tak wczytany zasób, czy może wczytywać go przed każdym użyciem. Parametr nazywa się no_cache i dając false deklarujemy, że chcemy żeby ten zasób został zapamiętany. Ale znowu, w GDScripcie ten argument jest domyślny.

W ten sposób otrzymujemy coś typu Option<Ref<Resource, Shared>>.
Aby móc sensownie skorzystać z otrzymanego zasobu potrzebujemy przekształcić go funkcją cast na PackedScene. Robimy to analogicznie jak w jednym z poprzednich wpisów, czyli najpierw prosimy dzieloną referencję o bezpieczny dostęp do zasobu funkcją assume_safe (pamiętając o tym, że jest ona unsafe), potem używamy funkcji cast żeby przekształcić go na PackedScene, wszystko robiąc wewnątrz Option, no i otrzymujemy Option<TRef<'a, PackedScene, Access>>.
Niestety nie jest to koniec. Rust mówi, że ma problem:

self.base_template = ResourceLoader::godot_singleton()
   |  ______________________________^
27 | |             .load("res://scenes/Base.tscn", "", false)
28 | |             .and_then(|scene| {
29 | |                 unsafe { scene.assume_safe().cast::<PackedScene>() }
30 | |             });
   | |______________^ expected struct `gdnative::Ref`, found struct `gdnative::TRef`

Rozwiązuje go funkcja claim(), która zamienia TRef na Ref. Teraz powinno byc ok. Nie jest to najwdzięczniejsza metoda używania zasobów, ale na razie musi nam wystarczyć. Może kiedyś znajdę ładniejszą.

Na końcu jeszcze sprawdzamy, czy uzyskaliśmy coś sensownego. Jeśli doszliśmy do None, coś poszło nie tak. Prawdopodobnym błędem może być literówka w argumentach load. Ten match służy tylko sprawdzeniu czy wszystko ok, można by go było pominąć.

Skoro nauczyliśmy już managera tworzyć kafelki, zajmijmy sie ich dodawaniem.

Dodawanie kafelków ziemi

Napiszmy sobie funkcję control_spawning, która zajmie sie dodawaniem kafelków, gdy zajdzie taka potrzeba. W argumencie będzie przyjmowała współrzędną x do której ma wygenerować ziemię.
Załóżmy na razie, że mamy już funkcję spawn_one, która umie dodać kafelek ziemi w podanej w argumencie pozycji. Wtedy funkcja control_spawning mogłaby wyglądać tak:

#[export]
pub fn control_spawning(&mut self, owner: &Node2D, x_end: f32) {
    while x_end > self.end_position {
        self.spawn_one(owner, self.end_position, -self.sprite_height);
        self.end_position += self.sprite_width;
    }
}

Pole end_position przechowuje współrzędną x prawego brzegu ostatnio wygenerowanego kafelka. Gdy x_end ja przekroczy, należy dodać nowy i zaktualizować end_position. Uzupełnijmy teraz szczegóły.

Funkcja spawn_one w argumentach przyjmuje pozycję, w której kafelek ma być dodany. Najpierw popatrzmy jak wygląda funkcja:

#[export]
fn spawn_one(&mut self, owner: &Node2D, x: f32, y: f32) {
    match self.base_template {
        Some(ref base_obj) => {
            let base = unsafe {
                // unsafe because `assume_safe` function using.
                base_obj
                    .assume_safe()
                    // Get instance of `PackedScene`.
                    .instance(0)
                    // Can be casted to `StaticBody2D` but `Node2D` is enough.
                    .and_then(|node| node.assume_safe().cast::<Node2D>())
                    .expect("Could not create base instance.")
            };
            base.set_position(euclid::Vector2D::new(x, y));
            // Add base to manager.
            owner.add_child(base, false);
        }
        None => print!("Base template error."),
    }
}

Chcemy, żeby ta funkcja wczytała strukturkę kafelka na podstawie pola base_template. Ponieważ jest typu Option, musimy je jakoś rozpakować. Dlatego zaczynamy od match. Zwróćmy uwagę na słowo kluczowe ref w ramieniu Some(ref base_obj). Dzięki temu Rust nie krzyczy. Jeśli je pominiemy, wypluje taki błąd:

match self.base_template {
   |               ^^^^^^^^^^^^^^^^^^ help: consider borrowing here: `&self.base_template`
49 |             Some(base_obj) => {
   |                  --------
   |                  |
   |                  data moved here
   |                  move occurs because `base_obj` has type `gdnative::Ref<gdnative::prelude::PackedScene>`, which does not implement the `Copy` trait

Skoro już udało nam się dostać do referencji do PackedScene, chcielibyśmy teraz jakoś stworzyć jej instancje. Służy do tego funkcja instance() ale zanim jej użyjemy musimy sie jeszcze odrobinę pogimnastykować. Mam tu na myśli prośbę o dostęp do PackedScene za pośrednictwem funkcji assume_safe. W końcu możemy zrobić sobie kafelka! Niestety, znów potrzebujemy trochę gimnastyki. Funkcja instance zwraca coś typu Option<Ref<Node, Shared>>. Wiemy już jak dostać się do wnętrza takiego czegoś, czyli potrafimy dostać się do nołda typu Node. Istotne jest tu, że chcemy ustawić pozycję tego świeżego nołda, a typ Node nie daje takiej możliwości. Dlatego od razu musimy go przekształcić na coś, co pozawala ustawić pozycję. Takim typem jest Node2D, jest nim też StaticBody2D, który nadaliśmy wcześniej (w edytorze Godota) kafelkowi ziemi. Obydwa powinny być ok, ja wybrałam sobie Node2D.

Po tej całej gimnastyce możemy w końcu ustawić pozycję kafelka ziemi i dodać ją do sceny.

W ten sposób doszliśmy do etapu, w którym działa dodawanie kafelków.

Usuwanie niepotrzebnych kafelków ziemi

Po tych dziwnych wygibasach związanych z dodawaniem kafelków, zajmijmy sie usuwaniem tych, które są już niepotrzebne (to znaczy niewidoczne dla kamery). Zamiast zastanawiać się nad warunkami, wykorzystamy do tego Godotowy element – czujnik widoczności i mechanizm sygnałów. Wróćmy zatem do edytora Godota.

Czujnik widoczności

Do sceny Base.tscn dodajmy nowy element – VisabilityNotifier2D. Będzie on pilnował, czy kafelki są widoczne w kamerze i pozwoli nam je usuwać, gdy będą niepotrzebne. Ponieważ w grze kamera porusza się zawsze w prawo, element znika z jej zasięgu gdy miniemy jego prawy brzeg. Dlatego pozycję czujniczka ustawiamy na szeokość obrazka, by nie usuwać jej zbyt wcześnie.

Czujnik umie całkiem sporo rzeczy. Możemy podejrzeć je w zakładce Node, zaraz obok zakładki Inspector.

Nas interesuje funkcja screen_exited. Powiążmy z nią funkcję, która ma obsłużyć zdarzenia wyjścia kafelka z widoku kamery. Oznacza to, że jeśli nastąpi zdarzenie wyjścia elementu z pola widzenia kamery, wywoła się ta powiązana funkcja. Ja zostawiłam nazwę zasugerowaną przez edytor: _on_notifier_screen_exited.

Teraz, gdy wszystko jest wstępnie przygotowane, dodajmy NativeScript do sceny Base i chodźmy do Rusta.

Cho do Rusta

Stwórzmy teraz strukturę kafelka ziemi w Ruscie. Dodajmy plik base.rs. Będzie on raczej prosty:

#[derive(NativeClass)]
#[inherit(StaticBody2D)]
pub struct Base;

#[methods]
impl Base {
    pub fn new(mut _owner: &StaticBody2D) -> Self {
        Base
    }

    #[export]
    pub fn _on_notifier_screen_exited(&self, owner: &StaticBody2D) {
        owner.queue_free();
    }
}

Zależy nam przede wszystkim na dodaniu funkcji _on_notifier_screen_exited. Gdy Godot zauważy, że element jest poza widocznością kamery, wywoła te funkcję. Na tym w przybliżeniu polegają godotowe sygnały. I w ten sposób, kafelki same zajmują się swoim usuwaniem.

Połączenie struktur BaseMenager i World

Określiliśmy podstawowe funkcjonalności menadżera ziemi. Teraz głównym problemem jest to, jak ich użyć. Do tej pory korzystaliśmy tylko z funkcji wynikających z dziedziczenia przez nasze struktury klasy Godotowej, jak na przykład ustawianie pozycji.

Tym razem nie wystarczą nam metody struktury Node2D (rodzica BaseMenagera). Potrzebujemy metod z samego BaseMenagera. Do tego posłuży nam struktura Instance. Pozwala ona na dostęp do funkcji z jednego NativeScriptu w drugim NativeScripcie. A tak całkiem konkretnie: chcemy wywołać funkcję BaseMenagera w strukturze World.

W strukturze World dodajmy pole

base_manager: Instance<BaseManager, Unique>,

W funkcji new dodajmy:

pub fn new(_owner: &Node2D) -> Self {
        World {
            base_manager: Instance::new(),
        }
    }

Właściwą wartość ustawimy dopiero w funkcji _ready przekształcając typ Godotowy w ten stworzony przez nas.

#[export]
fn _ready(&mut self, owner: &Node2D) {
        .
        .
        .
        // Base manager.
        let base_manager = owner
            .get_node("./BaseManager")
            .and_then(|m| unsafe { m.assume_unique().cast::<Node2D>() });
        match base_manager {
            Some(base) => {
                // Downcast a Godot base class to a NativeScript instance.
                self.base_manager = Instance::try_from_base(base).expect("Can't downcast to BaseManager");
            }
            None => {
                godot_print!("Problem with loading BaseManager node.");
            }
        }
    }

Na początku postępujemy podobnie jak z postacią gracza: w funkcji get_node podajemy ścieżkę do dziecka, które chcemy wczytać, a potem przekształcamy otrzymany Node na Node2D. Następnie sprawdzamy czy w wyniku tej operacji coś otrzymaliśmy. Jeśli nie, to piszemy w konsoli, że jest problem. Jeśli tak, próbujemy przekształcić Node2D na Instance<BaseManager> funkcją try_from_base, która zwraca Option. Żeby dostać się do instancji managera, używamy funkcji expect.

W funkcji _physics_process dodajemy następujący kod:

#[export]
fn _physics_process(&self, owner: &Node2D, _delta: f64) {
    .
    .
    .
    // Base management.
    self.base_manager
        .map_mut(|manager, owner| manager.control_spawning(owner.as_ref(), camera_x_range.1))
        .expect("Can't call manager's function: `control_spawning`");
    }
}

Aby wywołać funkcję control_spawning z managera, używamy funkcji map_mut. Jej argumentem jest dwuargumentowe λ-wyrażenie. Pierwszy argument (tego wyrażenia) to menadżer, czyli instancja BaseManagera, a drugi to owner. W ciele wywołuje funkcje menadżera. Musimy tylko podać w argumencie, gdzie manager ma skończyć generowanie kafelków.
No właśnie, gdzie?
Do tego potrzebujemy wyznaczyć zakres widoczności kamery w osi x. Możemy go wyznaczyć na podstawie współrzędnych kamery, jej offsetu i szerokości okna gry (przyjmijmy na razie, że wynosi ono 480px), na przykład tak:

    // Get camera view right bound.
    let camera_x_end = {
        let camera_x_start = new_position.x + camera.offset().x;
        camera_x_start + 480.0
    };

Tu jest obrazek. Ten jaśniejszy kawałek tła oznacza zakres tego co widzi kamera. Zwrócę tylko uwagę, że camera_offset jest ujemny, więc wszystkie plusy na obrazku są spoko.

Cała funkcja _physics_process w pliku world.rs wyglada tak:

#[export]
fn _physics_process(&self, owner: &Node2D, _delta: f64) {
    // Get TRef to camera Node.
    let camera = self.get_camera(owner);

    // Change only x position of camera to make it follow crabby.
    let new_position = {
        let camera_x = self.get_crabby(owner).global_position().x;
        let camera_y = camera.global_position().y;

        Vector2::new(camera_x, camera_y)
    };

    // Set camera to new position.
    camera.set_global_position(new_position);

    // Get camera view right bound.
    let camera_x_end = {
        let camera_x_start = new_position.x + camera.offset().x;
        camera_x_start + 480.
    };

    // Base management.
    self.base_manager
        .map_mut(|manager, owner| manager.control_spawning(owner.as_ref(), camera_x_end))
        .expect("Can't call menager's function: `manage_base`");
}

Efekt

Bonus

Po uruchomieniu napisanego kodu, gra nie wygląda jak na powyższym gifie. Widzimy w niej tylko ten fragment o jaśniejszym tle. Żeby popodglądać sobie to generowanie świata, trzeba zrobić jeszcze kilka rzeczy. Jest to zupełnie opcjonalne i służy tylko popatrzeniu sobie na znikające i pojawiające kafelki. Przedstawię tu pobieżnie jak to osiągnąć.

Najważniejszym krokiem, jest dodanie do projektu nowej kamery, która będzie śledziła tę dotychczasową, ale widziała więcej. No ale po kolei.

  1. W edytorze Godota dodajmy dodatkową kamerę jako dziecko dotychczasowej kamery (dzięki temu będzie ją śledzić). Ustawmy ją na kamerę aktywną zaznaczając pole Current i zmieniając współrzędną x pola offset na -400.0. Dzięki temu nowa kamera będzie widziała co się dzieje poza widokiem dotychczasowej kamery.

  2. Jako dziecko dotychczasowej kamery dodajmy też wierzchołek ColorRect. Ustawmy jego kolor na coś przezroczystego, a wymiary na 480x720px. Będzie udawał, że widzi to co dotychczasowa kamera. Ustawmy jego pozycję x na -120, czyli offset kamery.

  3. Żeby kafelki znikały poprawnie, musimy zmienić ustawienia czujnika w scenie Base.tscn. Ustawmy jego pozycję x na 350 - (400-120) = 70 (szerokość obrazka - (offset nowej kamery - offset starej kamery)). Wynika to ze zmiany kamery, chcemy żeby kafelki znikały jak wyjdą z zakresu widoczności starej kamery, a nie aktualnej.

  4. Ustawmy wymiary okna gry w ustawieniach projektu na 1000x720px (to przykładowa wartość; najważniejsze, żeby szerokość okna była większa niż 480). Project -> Project Settings -> Display -> Window

Podsumowanie

Nie jestem przekonana czy to właściwy sposób kontaktu między strukturami godotowymi. Zamiast takiego "siłowego" wywoływania funkcji z obcego NativeScriptu mogliśmy (chyba) użyć sygnałów. Dlatego w przyszłości chciałabym zająć się sygnałami i porównać te dwie metody. W kolejnym wpisie prawdopodobnie zajmę się generowaniem przeszkód. Ogólnie zapraszam.

Jeśli macie ochotę podzielić się wrażeniami, pomysłami, nadziejami albo innymi uwagami, serdecznie zachęcam.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.