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:
Uwaga
Jak najedziemy myszką na sygnał body_entered
pojawi nam się taki komunikat:
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.
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 typiuSome(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:
- 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 gocontrol_started
. W funkcjiregister_signal
dodajmy:
fn register_signals(builder: &ClassBuilder<Self>) {
…
builder.add_signal(Signal {
name: "control_started",
args: &[],
});
}
-
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 polaobstacle_status
. Dodajmy w plikuworld.rs
funkcję:#[export] fn handle_control_start(&mut self, _owner: &Node2D) { // Start obstacles generation. self.obstacle_status = true; }
-
Połączmy sygnał z funkcją-reakcją w funkcji
ready
strukturyWorld
.
…
// 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");
- 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.
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ą.
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
iset_collision_mask
.Przenieśmy to teraz do CrabbyBirda. Przy tworzeniu gracza gdzieś na początku funkcji
_ready
ustawmy odpowiedniocollision_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
iBase
, w której analogicznie w funkcji_ready
ustawmy collision_layer:
owner.set_collision_layer(2); // 2^1
(dla rur) iowner.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.