Kategorie
CrabbyBird

CrabbyBird #5 Obsługa kolizji

Dzisiejszy wpis jest szóstym odcinkiem z serii, w której w ramach nauki Rusta i Godota pisze sobie klon FlappyBirda.

Na tym etapie kraboptak potrafi skakać i zderzać się z przeszkodami. Podąża za nim kamera, a wokół niej tworzy się świat.

Wszystko fajnie, ale co to za gra, której nie da się przegrać? Poza tym generowanie przeszkód zaczyna się w z góry określonej pozycji, a chcemy by zachodziło dopiero po przejęciu kontroli nad kraboptakiem przez gracza. Te problemy rozwiążemy przez zmiany stanu gry i to jest dzisiejszym celem.

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

Stan gry

Póki co kraboptak umie tylko reagować na wciśnięcie spacji skokiem. Chcielibyśmy, żeby gra zaczęła się od lotu kraboptak, by gracz mógł przejąć kontrolę bez obawy, że przegra zanim gra się w ogóle rozpocznie. W trakcie lotu nie powinny być też generowane przeszkody. Właściwa gra powinna zacząć się dopiero po przejęciu kontroli nad kraboptakiem, pozwalając graczowi odrobinę przywyknąć do jego sposobu poruszania. Gdy gra się już rozpocznie, jakakolwiek kolizja postaci powinna zakończyć się przegraną.

Taką wizję można zrealizować zmianami stanu kraboptak. W zależności od niego będziemy uruchamiać lub zatrzymywać generowanie przeszkód.

Wyróżnijmy trzy takie stany:

  • flying - lot bez kontroli przez gracza,
  • flapping - pełna kontrola przez gracza, dokładnie to co mamy teraz,
  • dead - przegranko.

Zmiany między stanami związane są z bardzo jasnymi zdarzeniami:

  • flying -> flapping -- pierwsze wciśnięcie spacji,
  • flapping -> dead -- kolizja kraboptaka z przeszkodą lub ziemią.

Przygotowałam kilka animacji, które będziemy zmieniać przy okazji zmiany stanu.



Kolejne klatki możecie znaleźć w katalogu assets: flapping, flying i game over. Wskazówki jak zrobić z nich animację w godocie możecie znaleźć w jednym z poprzednich wpisów.

Skoro stany mamy wstępnie ustalone, przygotujmy sobie enuma w pliku player.rs,

pub enum PlayerState {
    Flying,
    Flapping,
    Dead,
}

A do struktury Player dodajmy pole state: PlayerState. Zainicjujmy go w funkcji new wartością: PlayerState::Flying, bo grę zawsze rozpoczynamy w takim stanie.

Teraz możemy się zastanowić, co będzie się działo z krabem w różnych stanach i sytuacjach. Zacznijmy od wciśnięcia przez gracza spacji. Należy wtedy sprawdzić aktualny stan.

Jeśli krab lata, gracz powinien przejąć nad nim kontrolę. Dlatego zmieniamy jego stan na Flapping i wywołujemy funkcję flap, aby podskoczył.

Jeśli w momencie wciśnięcia spacji aktualnym stanem jest Flapping, chcemy by krab podskoczył. Wywołujemy więc funkcję flap.

Jeśli krab jest aktualnie martwy, a gracz wcisnął spację, nie robimy nic.

Powyższe zachowania zakodujmy w funkcji physics_process:

fn _physics_process(&mut self, owner: &RigidBody2D, _delta: f64) {
    // Flap if space is pressed
    let input = Input::godot_singleton();
    if Input::is_action_pressed(&input, "ui_flap") {
        match self.state {
            PlayerState::Flying => {
                self.state = PlayerState::Flapping;
                self.flap(owner);
            }
            PlayerState::Flapping => self.flap(owner),
            PlayerState::Dead => {}
        }
    }
    .
    .
    .
}

Kolejnym miejscem, w którym należy rozważyć aktualny stan kraboptaka, jest dalsza część funkcji physics_process. Kod z którego korzystaliśmy do tej pory umieśćmy w przypadku, gdy aktualny stan to Flapping. Dla pozostałych przypadków stworzymy odpowiednie funkcje: fly i dead.

match self.state {
    PlayerState::Flapping => {
        owner.set_gravity_scale(10.);
        // Assure that player can't face up more than max facing_angle
        if owner.rotation_degrees() < self.max_facing_angle {
            owner.set_rotation_degrees(self.max_facing_angle);
            owner.set_angular_velocity(0.0);
        }
        // Set angular velocity when falling.
        if owner.linear_velocity().y > 0.0 {
            owner.set_angular_velocity(PI / 2.0);
        }

        // Set x of linear velocity.
        owner.set_linear_velocity(Vector2::new(self.x_speed, owner.linear_velocity().y))
    }
    PlayerState::Flying => {
        self.fly(owner, delta as f32);
    }
    PlayerState::Dead => {
        self.dead(owner);
    }
}

Jak można się domyślić, funkcja fly określa, jak kraboptak lata. Ustawimy w niej prędkość w osi x żeby dodać do ruchu kraba trochę szaleństwa, uzależnimy jego pozycję y od pozycji x używając funkcji sinus. Sprawi to, że będzie się delikatnie wahał. Poza tym uruchomimy animację lotu kraboptak.

fn fly(&self, owner: &RigidBody2D, delta: f32) {
        owner.set_gravity_scale(0.0);
        // Set horizontal velocity to move player forward with x_speed.
        // Don't change vertical position.
        owner.set_linear_velocity(Vector2::new(self.x_speed, 0.0));
        let pos = owner.global_position();
        // Make player swing a little.
        owner.set_global_position(Vector2::new(pos.x, pos.y + (pos.x * delta).sin()));

        // Start flying animation.
        self.get_jump_animation(owner).play("fly", true);
    }

Z kolei w funkcji dead zatrzymujemy ptakokraba ustawiając jego prędkość poziomą na 0 i uruchamiamy animację gameover.

fn dead(&self, owner: &RigidBody2D) {
    owner.set_linear_velocity(Vector2::new(0.0, owner.linear_velocity().y));
    self.get_jump_animation(owner).play("gameover", false);
    }

Jeśli chodzi o strukturę Player to w zasadzie tyle, obsłużyliśmy wszystkie stany. Jednak to nie wszystko. Potrzebujemy wysłać do świata informację, by zaczął już generować przeszkody. Poza tym player potrzebuje informacji o kolizjach. Zrobimy to używając sygnałów, podobnie jak w poprzednim wpisie. Chodźcie, będzie fajnie.

Sygnały

Sygnały godotowe

Sygnały posłużą nam do komunikacji świata z postacią gracza. Zacznijmy od czegoś klasycznego: wykrywanie kolizji. Będzie to coś podobnego, co robiliśmy przy ukrywaniu animacji dymku po tym jak krab przestaje skakać. Połączyliśmy tam swoją funkcję ze zdarzeniem skończenia się klatek animacji. Podobnie postępowaliśmy też przy Notifierze.

Tutaj chcemy zrobić swoje rzeczy, gdy kraboptak uderza w ziemię lub rurę. Przejdźmy do edytora Godota i otwórzmy scenę Player. Po prawej stronie obok zakładki Inspector mamy zakładkę Node w której jest lista sygnałów, które umie rzucać postać. Interesuje nas sygnał body_entered. Klikając go możemy połączyć go z funkcją-reakcją. Ja nazwałam ją _on_player_body_entered, a właściwie to sama się tak nazwała, bo zmieniłam tylko litery na małe. Po podłączeniu wygląda to tak:

Playerowe sygnały

Uwaga
Jak najedziemy myszką na sygnał body_entered pojawi nam się taki komunikat:

Warning

U mnie śmiga bez ustawiania tych wartości, nie do końca wiem czemu. Ale jeśli u Was by nie działało, może spróbujcie posłuchać się tego komunikatu. Być może są to warunki, żeby wykryć kolizję z innym RigidBody2D?

Mając wszystko ustawione w Godocie, pora przejść do Rusta. W pliku player.rs dodajmy funkcję o tej samej nazwie, która podłączyliśmy pod sygnał body_entered. U mnie _on_player_body_entered. Jej argumentami są standardowo self, owner i jeszcze Node z którym kraboptak wszedł w kolizje.

W momencie kolizji, chcemy zmienić stan kraba na Dead.

// Function connected with body_entered() event from Player node.
#[export]
fn _on_player_body_entered(&mut self, _owner: &RigidBody2D, _node: Ref<Node>) {
    self.state = PlayerState::Dead
}

Gdy kraboptak zderzy się z przeszkodą zatrzymuje się, uruchamia odpowiednią animację i zaczyna spadać. Nie reaguje też na wciskanie spacji. Bardzo dobrze.

Nasze własne sygnały

Teraz chcemy dać znać grze, że gracz przegrał. Do tego również zastosujemy sygnały, ale już własne, a nie domyślne.

Źródłem sygnału o zakończeniu gry, będzie struktura Player. W pliku player.rs tuż przed strukturą Player dodajmy atrybut:
#[register_with(Self::register_signals)].
Dzięki temu będziemy mogli skorzystać z funkcji register_signal, w której dodamy swój sygnał. Nazwijmy go game_over.

fn register_signals(builder: &ClassBuilder<Self>) {
        builder.add_signal(Signal {
            name: "game_over",
            args: &[],
        });
}

Po tej deklaracji możemy już emitować nasz nowy sygnał. Zróbmy to w momencie kolizji, czyli w funkcji _on_player_body_entered. Wygląda ona teraz tak:

// Function connected with body_entered() event from Player node.
#[export]
fn _on_player_body_entered(&mut self, owner: &RigidBody2D, _node: Ref<Node>) {
    self.state = PlayerState::Dead;
    // Emit game over signal
    owner.emit_signal("game_over", &[]);
}

W ten sposób ustawiliśmy emmitera sygnału. Zajmijmy się teraz subskrajberem, czyli obiektem zainteresowanym odbieraniem tego komunikatu. Będzie nim struktura World, która chce wiedzieć kiedy gra się skończy.

Uwaga

Po zbudowaniu projektu i otwarcia go w Godocie, możemy znaleźć nasz sygnał pośród sygnałów, które umie emitować postać gracza.

Nasz sygnał pośród innych

Moglibyśmy teraz połączyć ten sygnał z funkcją w strukturze World w edytorze Godota, tak jak robiliśmy to poprzednio. Tym razem jednak zróbmy to w Ruscie.

Jak już ustaliliśmy, sygnałem zainteresowana jest struktura World. Chodźmy zatem do pliku world.rs. Musimy połączyć sygnał struktury Player z funkcją w strukturze World. Zrobimy to w funkcji _ready (w pliku world.rs). Na jej końcu dodajmy kod:

// Connect game over signal.
self.get_crabby(owner)
    .connect(
        "game_over",
        owner,
        "handle_game_over",
        VariantArray::new_shared(),
        1,
    )
    .expect("Problem with connecting `game_over` signal");

Bierzemy postać kraba i łączymy jego sygnał game_over funkcją connect ze strukturą World ukrytą w argumencie owner.
W GDScripcie wyglądałoby to w skrócie tak.
<wierzchołek_źródłowy>.connect(<nazwa_sygnału>, <docelowy_wierzchołek>, )

Pozostałe argumenty są ustawione domyślnie. W Ruscie musimy je podać. Zatem u nas funkcja przyjmuje jako argumenty kolejno:

  • nazwę sygnału (w typie GodotString),
  • zainteresowaną strukturę, u nas Game, jednak funkcja chce, by było ona typiu Some(Object),
  • nazwę funkcji która będzie funkcją-reakcją (znowu typu GodotString),
  • listę argumentów dla metody związanej z sygnałem,
  • flagi. Wartość domyślna to 1.

Teraz zostało tylko stworzyć funkcję-reakcję w strukturze World. Póki co, będzie to tylko wyświetlanie napisu "Game Over" w konsoli. Zajmiemy się nią dokładnie w dalszych wpisach.

#[export]
fn handle_game_over(&self, _owner: &Node2D) {
    godot_print!("Game Over!")
    // TODO game over.
}

Teraz w przypadku kolizji kraboptaka z czymkolwiek dostajemy w konsoli komunikat o końcu gry.

Przejęcie kontroli

Zajmijmy się teraz sygnałem do świata, że gracz przejął kontrolę i można już generować rury. Sposób będzie analogiczny do tego, który zastosowaliśmy przed chwilą.

Na początek chodźmy do pliku world.rs. Proponuję dodać do świata pole obstacle_status, które będzie kontrolować czy rozpocząć generowanie rur. W funkcji _init ustawmy go na false. W funkcji physics_process w pliku world.rs przy fragmencie w którym decydujemy, czy potrzeba nowej rury uzupełnijmy warunek:

// Emit signal to pipe_manager if pipe is needed.
if camera_x_end - self.last_pipe_x > self.pipe_density && self.obstacle_status {
    …
}

Dzięki temu generowanie rur rozpocznie się dopiero, gdy wartość pola obstacle_status zmieni się na true. A kiedy się zmieni? Wtedy gdy struktura World wyłapie sygnał od struktury Player.

I teraz zaczyna się część analogiczna do tej powyższej:

  1. Zarejestrujmy sygnał. Będzie od pochodził ze struktury Player. (Dodaliśmy do niej już wcześniej atrybut #[register_with(Self::register_signals)]. Jeżeli chcielibyśmy rejestrować sygnał w innej strukturze, trzeba o tym pamiętać.)
    Nazwijmy go control_started. W funkcji register_signal dodajmy:
fn register_signals(builder: &ClassBuilder<Self>) {
      …
      builder.add_signal(Signal {
            name: "control_started",
            args: &[],
        });
    }
  1. Stwórzmy funkcję-reakcję. Strukturą zainteresowaną tym komunikatem będzie World. Reakcją na przejęcie kontroli przez gracza powinno być generowanie przeszkód. Osiągniemy to przez zmianę wartości pola obstacle_status. Dodajmy w pliku world.rs funkcję:

    #[export]
    fn handle_control_start(&mut self, _owner: &Node2D) {
    // Start obstacles generation.
    self.obstacle_status = true;
    }
  2. Połączmy sygnał z funkcją-reakcją w funkcji ready struktury World.

…
// Connect signal to start generating pipes.
self.get_crabby(owner)
  .connect(
    "control_started",
    owner,
    "notify_control_start",
    VariantArray::new_shared(),
    1,
    )
  .expect("Problem with connecting `control_started` signal");
  1. Wyemitujmy nowy sygnał przy pierwszym wciśnięciu spacji. Rozpoznamy to po aktualnym stanie kraba. Jeśli krab lata, spacja nie została jeszcze wciśnięta. Sygnał należy wyemitować w funkcji _physics_process, przy sprawdzaniu czy gracz wcisną spację:
let input = Input::godot_singleton();
if Input::is_action_pressed(&input, "ui_flap") {
    match self.state {
        PlayerState::Flying => {
            owner.emit_signal("control_started", &[]);
            self.state = PlayerState::Flapping;
            self.flap(owner);
        }
        PlayerState::Flapping => self.flap(owner),
        PlayerState::Dead => {}
    }
}

Ostatnie szlify

W ten sposób obsłużyliśmy zaplanowane interakcje między postacią kraba a światem gry. Zostało nam jeszcze kilka drobnych szlifów.

Mianowicie w przypadku, gdy ptak jest martwy wciąż odbija się od przeszkód. W oryginalnym FlappyBird, kolizje z rurami stają się nieaktywne. Zajmiemy się teraz odtworzeniem tego zachowania. Zatem nowy cel to wyłączenie kolizji z rurami i zachowanie kolizji z ziemią w przypadku przegrania rozgrywki.

Collision layers

Godot dostarcza całkiem fajne narzędzia do obsługi kolizji – warstwy i maski.

Każdy obiekt który umie w kolizje ma 20 warstw z którymi może współpracować. Można je wyklikać w edytorze Godota. To te niepozorne kwadraciki. Widzimy tam dwie grupy kwadracików:

  • Layer – warstwy na których obiekt się pojawia. Domyślnie wszystkie ciała pojawiają się na warstwie 1.
  • Mask – warstwy na których obiekt szuka kolizji. Obiekty które są na niezaznaczonych warstwach są ignorowane. Domyślna warstwa to ta pierwsza.

Żeby było wygodnie ustawimy teraz wszystkie rodzaje obiektów na oddzielnych warstwach. Możemy nadać im nazwy w ustawieniach projektu.

Zmiana nazwy warstwy

Samo ustawienie tych warstw można wyklikać w edytorze zaznaczając odpowiednie kwadraciki. My zrobimy to jednak w Ruscie, bo i tak potem będziemy to modyfikować w zależności od wydarzeń w grze. Przedtem przyjrzyjmy się jednak tym kwadracikom w Godocie. Gdy najedziemy na nie myszką, pojawia się takie małe okienko z nazwą
warstwy i jej wartością.

Wartość warstw

Zauważmy, że wartość to 2^Bit. Wykorzystamy to przy ustawianiu warstw kolizji w Ruscie.
Są co najmniej dwie możliwości: albo funkcją set_collision_layer albo set_collision_layer_bit.

  • set_collision_layer_bit ( int bit, bool value ) – funkcja przyjmuje w argumentach bit i wartość logiczną, która chce mu przypisać. Nadaje się tylko do ustawienia jednaj warstwy na raz,

  • set_collision_layer ( int value ) – funkcja w argumencie przyjmuje wartość, o której wspomnieliśmy wcześniej. Jeszcze fajniejsze jest to, że możemy w niej ustawić kilka warstw na raz sumując ich wartości.
    Na przykład jeśli chcemy, by obiekt znajdował się jednocześnie na warstwie pierwszej (czy jak kto woli zerowej) i trzeciej (licząc od zera – drugiej) wartość wyniesie: 2^0 + 2^2.

    Zupełnie analogicznie działa ustawianie masek kolizji . Do tego służą odpowiednio funkcje set_collision_mask_bit i set_collision_mask.

    Przenieśmy to teraz do CrabbyBirda. Przy tworzeniu gracza gdzieś na początku funkcji _ready ustawmy odpowiednio collision_layer. Zgodnie z tym jak nazwaliśmy wcześniej warstwy, przeznaczyliśmy dla gracza pierwszą warstwę (licząc od zera).

    Ustawmy od razu maski. Chcemy by na początku krab wchodził w kolizję zarówno z rurami jak i ziemią, dla których przeznaczyliśmy warstwy drugą i trzecią. Przypominam, że liczymy od zera.

    fn _ready(&mut self, owner: &RigidBody2D) {
    owner.set_collision_layer(1); // 2^0
    owner.set_collision_mask(6); // We want to set collision with 1 and 2 mask layer. 2^1 + 2^2 = 6
    …
    }

    Przejdźmy teraz do struktury Pipe i Base, w której analogicznie w funkcji _ready ustawmy collision_layer:
    owner.set_collision_layer(2); // 2^1(dla rur) i owner.set_collision_layer(4)(dla ziemi).

Póki co gra będzie zachowywała się dokładnie tak, jak wcześniej. Jednak przygotowaliśmy wszystko pod ostatnie szlify. Zostało nam tylko wyłączyć kolizje między rurami a krabem w przypadku skuchy, czyli w funkcji dead. Dodajmy w niej:

unsafe fn dead(&self, mut owner: RigidBody2D) {
    …
    owner.set_collision_mask(4); // 2^2
    …
    }

W ten sposób "unieważniamy" zderzenia kraba z rurami, a pozostawiamy z ziemią.

Efekt



Podsumowanie

W dzisiejszym wpisie zrobiliśmy dużo rzeczy. Grę można juz przegrać. Następnym razem zajmiemy się liczeniem punktów. 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.