Kategorie
CrabbyBird

CrabbyBird #4 Generowanie świata gry – cześć II

Dzisiejszy wpis jest piątym odcinkiem z serii, w której w ramach nauki Rusta i Godota pisze sobie klon FlappyBirda. Dziś zajmiemy się kontynuacją generowania świata gry: tym razem dodamy do niego przeszkody.

Pomysł jest trochę podobny jak przy generowaniu ziemi, którym zajmowaliśmy się w poprzednim wpisie. Jednak tym razem podejdziemy do problemu nieco inaczej – użyjemy godotowego mechanizmu sygnałów.

Pełny kod powstały w tym wpisie można znaleźć na githubie.

Dzisiejszy cel

Dzisiejszy problem jest nieco podobny do tematu ostatniego wpisu. Rozwiązanie mogłoby wyglądać w zasadzie tak samo, ale żeby się nie zanudzić, spróbujemy rozwiązać go trochę inaczej i porównać te dwa podejścia. Ogólnie celem jest dodawanie do gry rur i usuwanie ich, gdy są już zbędne. Zacznijmy więc!

Ustawienia sceny rury

Jak zwykle zaczniemy od ustawienia scen w edytorze Godota. W pierwszej kolejności zajmijmy się samą rurą. Rura w naszej grze będzie składała się tak na prawdę z dwóch rur – górnej i dolnej. Scena wygląda tak:

Scena rury

A tutaj są ustawienia poszczególnych elementów:

Ustawienia rury

Po pierwsze zwróćmy uwagę, że w zakładce Offset wyłączona jest opcja centered. Dzięki temu pozycja obrazka wyznaczana jest względem jego lewego górnego rogu.
Zarówno do rury górnej jak i dolnej używamy tej samej tekstury. Jedną z nich obróćmy o 180 stopni. Żeby obydwie były równo ułożone w pionie, trzeba jedną z nich przesunąć o szerokość obrazka (którą można podejrzeć w okienku edycji tekstury). Stąd ustawiłam rurze górnej (tej odwróconej) pozycję x na 52.
Poza tym pozycję y ustawiłam na 90 i -90. Wystarczy jeszcze dodać dwa CollisionShape tak, żeby pokrywały dodane Sprajty.

Czujnik widoczności

Podobnie jak przy generowaniu ziemi, dodajmy jeszcze jeden element – VisabilityNotifier2D. Będzie on pilnował, czy rury są widoczne w kamerze i pozwoli je usuwać, gdy są niepotrzebne. Ponieważ w naszej grze kamera porusza się zawsze w prawo, element znika z jej zasięgu gdy miniemy jego prawy brzeg. Dlatego pozycję czujniczka ustawiamy na szerokość obrazka rury, by nie usuwać jej zbyt wcześnie.

Sygnały, które umie wysyłać czujnik, możemy zobaczyć w zakładce Node.

Sygnały

Nas interesuje funkcja screen_exited. Powiążmy z nią funkcję, która ma obsłużyć zdarzenia wyjścia rury z widoku kamery. U mnie nazywa się: _on_pipe_screen_exited.

Teraz gdy wszystko jest wstępnie przygotowane, dodajmy NativeScript do stworzonej sceny Pipe i chodźmy do Rusta. Jeśli nie miałeś jeszcze okazji dodawać NativeScriptu do wierzchołka to tutaj możesz znaleźć podpowiedź jak to zrobić.

Rzeczy, za które rura może odpowiadać sama

Stwórzmy teraz strukturę rury w Ruscie. Będzie ona bardzo podobna do struktury Base. Dodajmy plik pipe.rs a w nim strukturkę:


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

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

    #[export]
    pub fn _on_pipe_screen_exited(&self, owner: &StaticBody2D) {
        println!("Remove pipe!");
        owner.queue_free();
    }
}

Żeby rura mogła pilnować swojego bycia albo niebycia, musi być w kontakcie z Notifierem. Zapewnia to funkcja on_pipe_screen_exited. Jeśli chcielibyście zobaczyć odrobinę dokładniejsze omówienie tego problemu, to odsyłam do takiego samego rozwiązania przy generowaniu ziemi.

I to wszystko. Każda rura usuwa się teraz sama.

Edit

Otóż nie działa to do końca dobrze. Jak się przyjrzymy, to czasem nieoczekiwanie znikają dopiero co dodane rury. Dzieje się tak, gdy gracz zderzy się z przeszkodą i ruszy się minimalnie w tył. Wtedy kamera, która go śledzi też się cofnie. Jeśli zdarzy się, że w tym momencie na scenie, na skraju widoczności kamery pojawi się nowa rura, to takie minimalne cofnięcie powoduje, że rzuci ona sygnał screen_exited, co spowoduje jej usunięcie.

Rozwiązaniem tego problemu jest zmiana pozycji czujnika widoczności w scenie Pipe.tscn na taki, by rura znikała nieco później. Ja ustawiłam jej współrzędną x na 52 px.

PipeManager

Pipe manager w Godocoe

Podobnie jak w przypadku ziemi, potrzebujemy Aranżatora Rur. Tym razem ograniczymy trochę jego zadanie w porównaniu z menadżerem ziemi. Będzie odpowiedzialny tylko za dodawanie rur. Za to jak często będą sie one generować będzie odpowiadała struktura World. Ale powoli.

W pliku pipe_manager.rs stwórzmy strukturę PipeManager.

pub struct PipeManager {
    pipe_template: Option<Ref<PackedScene>>,
    maximal_sprite_height: f32, // Maximal pipe height.
    minimal_sprite_height: f32, // Minimal pipe height.
    pipe_offset: f32,           // Half of space between up and down pipe.
}

Rury muszą sensownie mieścić się na ekranie. Na przykład otwór między rurami nie powinien być poza widocznym obszarem. Lepiej opisze to obrazek:

Poziomy rur

Jak widzicie, kraboptak jest naprawdę szczęśliwy tylko, gdy rury są ułożone tak akurat. Właśnie do tego posłużą nam pola maximal_sprite_height, minimal_sprite_height i pipe_offset.

Wymiary rur

W funkcji new inicjalizujemy co możemy, a funkcja _ready jest w zasadzie taka sama jak ta w BaseManager z dokładnością do ścieżki do pliku sceny.

#[methods]
impl PipeManager {
    pub fn new(_owner: &Node2D) -> Self {
        PipeManager {
            pipe_template: None,
            maximal_sprite_height: 640.0,
            minimal_sprite_height: 50.0,
            pipe_offset: 90.0,
        }
    }

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

        match &self.pipe_template {
            None => godot_print!("Could not load child scene. Check name."),
            _ => {}
        }
}

Zanim zajmiemy się logiką odpowiedzialną za dodawanie rur, przyjrzyjmy się z grubsza godotowemu mechanizmowi sygnałów i tego, jak możemy go wykorzystać.

Sygnały

W skrócie i uproszczeniu: jakiś obiekt rzuca komunikat, że coś się stało a na niego reagują inne zainteresowane obiekty.

Sygnały posłużą nam do komunikacji świata z Aranżatorem Rur. Tak na prawdę to juz ich używaliśmy, na przykład przy ukrywaniu animacji dymku po tym jak krab przestaje skakać, albo usuwaniem kafelków ziemi, czy rur gdy wyjdą z zakresu widoczności kamery. Łączyliśmy tam swoje funkcje ze zdarzeniem. Były to sygnały, które wypluwają elementy godotowe.
Tym razem zrobimy swoje własne, niestandardowe sygnały, rzucane i odbierane po stronie rusta.

Mamy dwa elemty związane z rurami: świat, który wie kiedy rura jest potrzebna (bo ma dostęp do kamery) i aranżatora rur, który wie jak taką rurę dodać. Chcemy je skomunikować. Emiterem zdarzenia będzie świat – obliczy sobie czy powinna pojawić się już nowa rura. Natomiast odbiorcą będzie aranżator, który doda rurę w określonej pozycji x. Pozycję y rury i sposób jej dodania określi już sam.

Ok, skoro plan jest jasny, to popiszmy sobie.

Sama funkcja add_pipe też wygląda podobnie do jej odpowiednika z BaseManagera, czyli funkcji spawn_one, którą możecie znaleźć w poprzednim wpisie.

#[export]
fn add_pipe(&mut self, owner: &Node2D, x: f32, y: f32) {
    match self.pipe_template {
        Some(ref pipe_obj) => {
            let pipe = unsafe {
                // unsafe because `assume_safe` function using.
                pipe_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.")
            };
            pipe.set_position(euclid::Vector2D::new(x, y));
            // Add base to manager.
            owner.add_child(pipe, false);
       }
       None => print!("Pipe template error."),
    }
}

Skoro mamy już funkcję, która umie dodawać rurę w podanej pozycji, zastanówmy się jak i kiedy jej użyć. Jak już wspominałam, rura powinna dodać się, gdy struktura World tak stwierdzi. Przejdźmy do pliku wordl.rs.

Tuż przed strukturą World dodajmy linijkę:

.
.
.
#[register_with(Self::register_signals)]
pub struct World {
    .
    .
    .
}

Dzięki temu w funkcji register_signal, będziemy mogli dodać swój sygnał. Nazwijmy go pipe_needed. Wraz z sygnałem chcemy przesłać współrzędną x, w której należy dodać rurę. Weźmy się za jej implementację:

fn register_signals(builder: &ClassBuilder<Self>) {
    builder.add_signal(Signal {
        name: "pipe_needed",
        args: &[SignalArgument {
            name: "position_x",
            default: Variant::from_i64(100),
            export_info: ExportInfo::new(VariantType::I64),
            usage: PropertyUsage::DEFAULT,
       }],
    });
}

Aby dodać sygnał potrzebujemy użyć kilka struktur z biblioteki gdnative: ClassBuilder, Signal i SignalArgument. Funkcji add_signal dajemy w argumencie sygnał, do którego stworzenia potrzebujemy nazwy oraz listy argumentów. Z kolei struktura SignalArgument potrzebuje nazwy argumentu domyślnej wartości opakowanej w strukturę Variant, ustawmy go na 100 (bez jakichś wyraźnych powodów). Nie wiem co robią pozostałe pola w strukturze SignalArgument.
Po tej deklaracji możemy już emitować nasz nowy sygnał. Tylko kiedy?

Żeby świat umiał to stwierdzić, potrzebuje trochę więcej danych. Dodajmy do struktury World następujące pola:

  • pipe_density oznaczające zagęszczenie rur,
  • last_pipe_x oznaczające pozycję ostatnio dodanej rury.
pub struct World {
    .
    .
    .
    // Pipe spawning settings.
    pipe_density: f32,
    last_pipe_x: f32,
}

Na podstawie nowych pól i pozycji kamery powinniśmy być w stanie określić, czy rura jest już potrzebna. Przypomnę tylko, że wcześniej wyznaczyliśmy sobie już prawy brzeg tego, co widzi kamera:

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

Skorzystajmy z tej zmiennej.

// Emit signal to pipe_manager if pipe is needed.
if camera_x_end - self.last_pipe_x > self.pipe_density {
    self.last_pipe_x = camera_x_end;
    owner.emit_signal("pipe_needed", &[Variant::from_i64(camera_x_end as i64)]);
}

Mam nadzieję, że obrazek pomoże w zrozumieniu warunku. Pomarańczowa strzałka oznacza lewą stronę nierówności. Gdy tylko będzie dłuższa, niż strzałka oznaczająca zagęszczenie rur, należy zaktualizować pole last_pipe_x i dodać nową rurę.

Własnie w tym momencie należy wyemitować wcześniej zarejestrowany sygnał - pipe_needed. Służy do tego funkcja emit signal. W argumentach bierze nazwę sygnału, oraz slajsa argumentów, które będa potrzebne funkcji reakcji. Argumenty z kolei sa opakowane w strukturkę Variant, a to po to, by godot umiał je przesłac między scenami.

Ok, skoro sygnał został wyemitowany, nauczmy teraz strukturkę PipeManager go dobierać i odpowiednio reagować.
Chodźmy do pliku pipe_manager.rs.

Celem bedzie napisanie funkcji-reakcji na sygnał pipe_needed. Zanim zajmiemy się jej implementacją, powiedzmy godotowi, że własnie ona ma być powiązana z tym sygnałem. W funkcji _ready dodajmy poniższy kod.

#[export]
    fn _ready(&mut self, owner: TRef<Node2D>) {
       .
       .
       .
        // Get emitter of `pipe_needed` signal.
        let emitter = owner
            .get_parent()
            .and_then(|node| unsafe { Some(node.assume_safe()) })
            .expect("Can't get emitter node.");

        emitter
            .connect(
                "pipe_needed",
                owner,
                "pipe_needed",
                VariantArray::new_shared(),
                0,
            )
            .unwrap();
    }

Aby połączyć sygnał, potrzebujemy mieć dostęp do nołda, który go emituje. Dostaniemy się do niego za pośrednictwem argumentu owner wywołując funkcję get_parent, a następnie prosząc o referencję do niego funkcją assume_safe.
Mając już dostęp do emmitera wywołujemy na nim funkcję connect. Za argumenty bierze kolejno: nazwę sygnału, wierzchołek, który ma na niego reagować, nazwę funkcji-reakcji, tablicę argumentów dla tej funkcji oraz flagi (nie zagłębiałam się co oznaczają, zgodnie z dokumentacją domyślną wartością flagi jest 0).

Zgodnie z powyższym kodem, potrzebujemy teraz funkcji reakcji o nazwie pipe_needed. Ze struktury World otrzymamy w argumentach:

  • współrzędną x w której należy dodac rurę,
  • dolny margines, czyli miejsce poniżej której rura nie powinna się generować; w praktyce bedzie to wysokość kafelka ziemi,
  • wysokość ekranu.

Takie dane wystarczą, żeby dobrze umieścić rurę w świecie. Napisaliśmy już funkcję, która dodaje do sceny rurę w podanej pozycji. W końcu możemy zajać się już samą funkcją pipe_needed. Jej zadaniem będzie określenie gdzie nalezy dodać rurę.

Chcemy generować przeszkody na losowych wysokościach, ale tak by żadna z rur nie wygenerowała się poza obszarem, który widzi kamera. Polami minimal_pipe_height i pipe_offset określiliśmy pewien margines generowanych rur. Pozycja y przeszkody musi być zatem oddalona od brzegów gry co najmniej o ich sumę. Należy uwzględnić jeszcze, że nie mamy do dyspozycji całej wysokości ekranu, bo dostępny obszar ogranicza nam ziemia. Stąd pojawia się dodatkowy argument funkcji – screen_bottom_margin. Pobieramy ją w argumencie, bo nie ma powodu, żeby manager rur wiedział cokolwiek o kafelkach ziemii. W argumencie pobieramy też wysokość okna gry.

#[export]
fn pipe_needed(
    &mut self,
    owner: &Node2D,
    position_x: Variant,
    screen_bottom_margin: Variant,
    screen_height: Variant,
) {
    // Parse arguments.
    let screen_height = screen_height.to_f64() as f32;
    let screen_margin = screen_bottom_margin.to_f64() as f32;
    // Calculate range of the pipe y position.
    let top_margin = self.minimal_sprite_height + self.pipe_offset;
    let bottom_margin =
        screen_height - (screen_margin + (self.minimal_sprite_height + self.pipe_offset));

    // Choose random y position in given range.
    let mut rng = thread_rng();

    // Top and bottom margins are negative, so order is reverse.
    let y = rng.gen_range(top_margin, bottom_margin);

    self.spawn_one(owner, position_x.to_f64() as f32, y)
}

Na pocztku zajmujemy się parsowaniem argumentów z typu Variant na f32. Następnie wyznaczamy zakres wysokości, na których może pojawić się rura. Przechowujemy go w zmiennych top_margin i bottom_margin. Następnie, losujemy pozycję y z tego zakresu i wywołujemy funkcję spawn_one. I voala, mamy rurę, która powinna być widoczna dla gracza.

Podsumowanie I

Po opisanych zmianach tworzenie świata wokół kraboptaka przebiega tak:

Efekt

Zarówno rury jak i ziemia tworzą się w razie potrzeby. Gdy są już niewidoczne, znikają ze sceny.

W przeciwieństwie do sposobu generowania ziemi, nie musimy dodawać aranżatora rur do świata jako pole. Struktury porozumiewają się za pośrednictwem sygnałów i całe rozwiązanie zyskuje przez to na czytelności. Tak mi sie przynajmniej wydaje.

Edit

W orginalnym FlappyBirdzie rury są znacznie szersze. Aby to osiągnąć, proponuję zmienić skalę w scenie Pipe.
Możemy zrobić to w edytorze Godota. Zdaje się, że zadziałałaby zmiana skali w wierzchołku Pipe (StaticBody2D), ale nie zaleca się zmieniać własności scale dla CollisionShape2D. Może to prowadzić do "unexpected collision behavior". U mnie nic się nie stało, świat działał jak powinien, ale na wszelki wypadek zmieniłam kolejno własności wierzchołków-dzieci:

  1. Skalę w wierzchołkach PipeDown i PipeUp na 1.5 (zarówno x jak i y),
  2. Odległości między dolną a górną rurą, zmieniając pozycję y każdej z nich. Ja ustawiłam je odpowiednio na 70 i -70, watrości wybrałam eksperymentalnie,
  3. Wymiary i pozycję CollisionShape2D, tak by pokryły one obrazki rur a skala pozostała bez zmian,
  4. pipe_offset w pliku pipe_manager.rs, ustawiłam na 70.

    Oto efekt:

Efekt

Sam kraboptak też jest trochę za duży. W tym wypadku zmienienie pola scale w wierzchołu Player nie pomoże. Wynika to stąd, że wierzchołków typu RigidBody2D nie można skalować. Jeśli spróbujemy to zrobić, to przy nazwie wierzchołka pojawi się ostrzeżenie, a skalowanie nie będzie działało. Problem możemy rozwiązać przez zmianę skali wszystkich jego dzieci. Ja ustawiłam skalę 0.6 (x i y) dla wierzchołka AnimatedSprite i skalę x na 0.5 a y na 0.7 dla PuffAnimation. Tak jak wcześniej, nie zaleca się zmieniać własności scale dla CollisionShape2D, więc dostosujmy jego wymiary ręcznie.

Poza tym nie podoba mi się teraz ustawienie rur. Oświetlenie jest złe. Nie wiem jak wcześniej tego nie zauważyłam. Żeby to naprawić, trzeba odrobinę zmodyfikować ustawienia górnej rury. Ustawmy jej pole scale x na -1.5, a pozycję x na 0.0.

To efekt po powyższych poprawkach:

Lepsze rury

Podsumowanie II

W ten sposób skończyliśmy generowanie świata, a gra zyskała istotny element – przeszkody. Co prawda jeszcze nic nie robią, ale to pierwszy krok do dania graczowi możliwości przegrania! Kolejne kroki już wkrótce. Cieplutko 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.