Skip to content

C++ multithread client side of the multiplayer Bomberman game - uses both TCP and UDP protocols

Notifications You must be signed in to change notification settings

PBundyra/multithread-bomberman-game

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Bomberman

I was an author of the C++ client side. Rust GUI was implemented by @agluszak.

This repository contains a client side for the Bomberman game. Using GUI user can enter input which is then parsed by the client and send to server. Client listens to the server and updates map displayed with GUI.

The communication between client and GUI is done via UDP protocol.
The communication between client and server is done via TCP protocol.

A manual and protocol are described in detail below.

0. Dostarczone programy

Do uruchomienia programów potrzeba kompilatora Rusta, a także pewnych bibliotek systemowych.

Po zainstalowaniu kompilatora należy wykonać komendę: cargo run --bin <gui/verifier> i uzupełnić parametry.

Skompilowany serwer (bynajmniej nie wzorcowy) jest dostępny tutaj. Został on skompilowany na maszynie students. Aby wyświetlały się komunikaty, należy uruchomić go ze zmienną środowiskową RUST_LOG=debug.

0.1. GUI

Interfejs graficzny dla gry Bombowe Roboty. GUI prawdopodobnie będzie jeszcze aktualizowane.

Sterowanie

W, strzałka w górę -  porusza robotem w górę.
S, strzałka w dół - porusza robotem w dół.
A, strzałka w lewo - porusza robotem w lewo.
D, strzałka w prawo - porusza robotem w prawo.
Spacja, J, Z - kładzie bombę.
K, X - blokuje pole.

0.2. Weryfikator

Ten program pozwala sprawdzić, czy wiadomości są poprawnie serializowane. Innymi słowy, jest to wzorcowy deserializator. Można łączyć się z nim zarówno po TCP, jak i UDP (z parametrem -u). Przy uruchamianiu należy podać, jakiego rodzaju wiadomości mają być sprawdzane.

Przykładowo, jeśli chcemy sprawdzić, czy klient wysyła prawidłowe wiadomości do serwera, wykonać: cargo run --bin verifier -- -p <port, na którym klient myśli, że serwer nasłuchuje> -m client

1. Gra Bombowe roboty

1.1. Zasady gry

Tegoroczne duże zadanie zaliczeniowe polega na napisaniu gry sieciowej - uproszczonej wersji gry Bomberman. Gra rozgrywa się na prostokątnym ekranie. Uczestniczy w niej co najmniej jeden gracz. Każdy z graczy steruje ruchem robota. Gra rozgrywa się w turach. Trwa ona przez z góry znaną liczbę tur. W każdej turze robot może:

  • nic nie zrobić
  • przesunąć się na sąsiednie pole (o ile nie jest ono zablokowane)
  • położyć pod sobą bombę
  • zablokować pole pod sobą

Gra toczy się cyklicznie - po uzbieraniu się odpowiedniej liczby graczy rozpoczyna się nowa rozgrywka na tym samym serwerze. Stan gry przed rozpoczęciem rozgrywki będziemy nazywać Lobby.

1.2. Architektura rozwiązania

Na grę składają się trzy komponenty: serwer, klient, serwer obsługujący interfejs użytkownika. Należy zaimplementować serwer i klienta. Aplikację implementującą serwer obsługujący graficzny interfejs użytkownika (ang. GUI) dostarczamy.

Serwer komunikuje się z klientami, zarządza stanem gry, odbiera od klientów informacje o wykonywanych ruchach oraz rozsyła klientom zmiany stanu gry. Serwer pamięta wszystkie zdarzenia dla bieżącej partii i przesyła je w razie potrzeby klientom.

Klient komunikuje się z serwerem gry oraz interfejsem użytkownika.

Zarówno klient jak i serwer mogą być wielowątkowe.

Specyfikacje protokołów komunikacyjnych, rodzaje zdarzeń oraz formaty komunikatów i poleceń są opisane poniżej.

1.3. Parametry wywołania programów

Serwer:

    -b, --bomb-timer <u16>
    -c, --players-count <u8>
    -d, --turn-duration <u64, milisekundy>
    -e, --explosion-radius <u16>
    -h, --help                                   Wypisuje jak używać programu
    -k, --initial-blocks <u16>
    -l, --game-length <u16>
    -n, --server-name <String>
    -p, --port <u16>
    -s, --seed <u32, parametr opcjonalny>
    -x, --size-x <u16>
    -y, --size-y <u16>

Klient:

    -d, --gui-address <(nazwa hosta):(port) lub (IPv4):(port) lub (IPv6):(port)>
    -h, --help                                 Wypisuje jak używać programu
    -n, --player-name <String>
    -p, --port <u16>                           Port na którym klient nasłuchuje komunikatów od GUI
    -s, --server-address <(nazwa hosta):(port) lub (IPv4):(port) lub (IPv6):(port)>

Interfejs graficzny:

    -c, --client-address <(nazwa hosta):(port) lub (IPv4):(port) lub (IPv6):(port)>
    -h, --help                               Wypisuje jak używać programu
    -p, --port <u16>                         Port na którym GUI nasłuchuje komunikatów od klienta

Do parsowania parametrów linii komend można użyć funkcji getopt z biblioteki standardowej C lub modułu program_options z biblioteki Boost.

Wystarczy zaimplementować rozpoznawanie krótkich (-c, -x itd.) parametrów.

2. Protokół komunikacyjny pomiędzy klientem a serwerem

Wymiana danych odbywa się po TCP. Przesyłane są dane binarne, zgodne z poniżej zdefiniowanymi formatami komunikatów. W komunikatach wszystkie liczby przesyłane są w sieciowej kolejności bajtów, a wszystkie napisy muszą być zakodowane w UTF-8 i mieć długość krótszą niż 256 bajtów.

Napisy (String) mają następującą reprezentację binarną: [1 bajt określający długość napisu w bajtach][bajty bez ostatniego bajtu zerowego].

Listy są serializowane w postaci [4 bajty długości listy][elementy listy]. Mapy są serializowane w postaci [4 bajty długości mapy][klucz][wartość][klucz][wartość]....

Pola w strukturze serializowane są bezpośrednio po bajcie oznaczającym typ struktury.

Należy wyłączyć algorytm Nagle'a (tzn. ustawić flagę TCP_NODELAY).

2.1. Komunikaty od klienta do serwera

enum ClientMessage {
    [0] Join { name: String },
    [1] PlaceBomb,
    [2] PlaceBlock,
    [3] Move { direction: Direction },
}

Typ Direction ma następującą reprezentację binarną:

enum Direction {
    [0] Up,
    [1] Right,
    [2] Down,
    [3] Left,
}

Wiadomość od klienta Join(“Żółć!”) zostanie zserializowana jako ciąg bajtów [0, 9, 197, 187, 195, 179, 197, 130, 196, 135, 33], gdzie:

0 - rodzaj wiadomości
9 - długość napisu
197, 187 - 'Ż'
195, 179 - 'ó'
197, 130 - 'ł'
196, 135 - 'ć'
33 - '!'

Natomiast wiadomość Join(“👩🏼‍👩🏼‍👧🏼‍👦🏼🇵🇱”) zostanie zserializowana jako ciąg bajtów [0, 49, 240, 159, 145, 169, 240, 159, 143, 188, 226, 128, 141, 240, 159, 145, 169, 240, 159, 143, 188, 226, 128, 141, 240, 159, 145, 167, 240, 159, 143, 188, 226, 128, 141, 240, 159, 145, 166, 240, 159, 143, 188, 240, 159, 135, 181, 240, 159, 135, 177].

Wiadomość Move(Down) zserializowana zostanie jako ciąg bajtów [3, 2].

Klient po podłączeniu się do serwera zaczyna obserwować rozgrywkę, jeżeli ta jest w toku. W przeciwnym razie może zgłosić chęć wzięcia w niej udziału, wysyłając komunikat Join.

Serwer ignoruje komunikaty Join wysłane w trakcie rozgrywki. Serwer ignoruje również komunikaty typu innego niż Join w Lobby.

2.2. Komunikaty od serwera do klienta

enum ServerMessage {
    [0] Hello {
        server_name: String,
        players_count: u8,
        size_x: u16,
        size_y: u16,
        game_length: u16,
        explosion_radius: u16,
        bomb_timer: u16,
    },
    [1] AcceptedPlayer {
        id: PlayerId,
        player: Player,
    },
    [2] GameStarted {
            players: Game<PlayerId, Player>,
    },
    [3] Turn {
            turn: u16,
            events: List<Event>,
    },
    [4] GameEnded {
            scores: Game<PlayerId, Score>,
    },
}

Wiadomość od serwera typu Turn

ServerMessage::Turn {
        turn: 44,
        events: [
            Event::PlayerMoved {
                id: PlayerId(3),
                position: Position(2, 4),
            },
            Event::PlayerMoved {
                id: PlayerId(4),
                position: Position(3, 5),
            },
            Event::BombPlaced {
                id: BombId(5),
                position: Position(5, 7),
            },
        ],

będzie miała następującą reprezentację binarną:

[3, 0, 44, 0, 0, 0, 3, 2, 3, 0, 2, 0, 4, 2, 4, 0, 3, 0, 5, 0, 0, 0, 0, 5, 0, 5, 0, 7]

3 - rodzaj wiadomości od serwera (`Turn`)
0, 44 - numer tury
0, 0, 0, 3 - liczba zdarzeń
2 - rodzaj zdarzenia (`PlayerMoved`)
3 - id gracza
0, 2 - współrzędna x
0, 4 - współrzędna y
2 - rodzaj zdarzenia (`PlayerMoved`)
4 - id gracza
0, 3 - współrzędna x
0, 5 - współrzędna y
0 - rodzaj zdarzenia (`BombPlaced`)
0, 0, 0, 5 - id bomby
0, 5 - współrzędna x
0, 7 - współrzędna y

Dostarczymy program do weryfikowania poprawności danych.

2.3. Definicje użytych powyżej rekordów

Event:
[0] BombPlaced { id: BombId, position: Position },
[1] BombExploded { id: BombId, robots_destroyed: List<PlayerId>, blocks_destroyed: List<Position> },
[2] PlayerMoved { id: PlayerId, position: Position },
[3] BlockPlaced { position: Position },

BombId: u32
Bomb: { position: Position, timer: u16 },
PlayerId: u8
Position: { x: u16, y: u16 }
Player: { name: String, address: String }
Score: u32

Pole address w strukturze Player może reprezentować zarówno adres IPv4, jak i adres IPv6.

Liczba typu Score informuje o tym, ile razy robot danego gracza został zniszczony.

2.4. Generator liczb losowych

Do wytwarzania wartości losowych należy użyć poniższego deterministycznego generatora liczb 32-bitowych. Kolejne wartości zwracane przez ten generator wyrażone są wzorem:

r_0 = (seed * 48271) mod 2147483647
r_i = (r_{i-1} * 48271) mod 2147483647

gdzie wartość seed jest 32-bitowa i jest przekazywana do serwera za pomocą parametru -s. Jeśli ten parametr nie jest zdefiniowany, można jako wartości domyślnej użyć dowolnej liczby, która będzie zmieniać się przy każdym uruchomieniu, np. unsigned seed = time(NULL) (C) lub unsigned seed = std::chrono::system_clock::now().time_since_epoch().count() (C++).

Powyższy generator odpowiada generatorowi std::minstd_rand.

Należy użyć dokładnie takiego generatora, żeby umożliwić automatyczne testowanie rozwiązania (uwaga na konieczność wykonywania pośrednich obliczeń na typie 64-bitowym).

Przykłady użycia generatora zostały podane w plikach c/random.c oraz cpp/random.cpp.

2.5. Stan gry

Serwer jest „zarządcą” stanu gry, do klientów przesyła informacje o zdarzeniach. Klienci je agregują i przesyłają zagregowany stan do interfejsu użytkownika. Interfejs nie przechowuje w ogóle żadnego stanu.

Serwer powinien przechowywać następujące informacje:

  • lista graczy (nazwa, adres IP, numer portu)
  • stan generatora liczb losowych (innymi słowy stan generatora NIE restartuje się po każdej rozgrywce)

Oraz tylko w przypadku toczącej się rozgrywki:

  • numer tury
  • lista wszystkich tur od początku rozgrywki
  • pozycje graczy
  • liczba śmierci każdego gracza
  • informacje o istniejących bombach (pozycja, czas)
  • pozycje istniejących bloków

Lewy dolny róg planszy ma współrzędne (0, 0), odcięte rosną w prawo, a rzędne w górę.

Klient powinien przechowywać zagregowany stan tak, aby móc wysyłać komunikaty do GUI. W szczególności klient powinien pamiętać, ile razy dany robot został zniszczony (aby móc wysłać tę informację w polu scores).

2.6. Podłączanie i odłączanie klientów

Klient wysyła komunikat Join do serwera po otrzymaniu dowolnego (poprawnego) komunikatu od GUI, o ile klient jest w stanie Lobby (tzn. nie otrzymał od serwera komunikatu GameStarted).

Po podłączeniu klienta do serwera serwer wysyła do niego komunikat Hello. Jeśli rozgrywka jeszcze nie została rozpoczęta, serwer wysyła komunikaty AcceptedPlayer z informacją o podłączonych graczach. Jeśli rozgrywka już została rozpoczęta, serwer wysyła komunikat GameStarted z informacją o rozpoczęciu rozgrywki, a następnie wysyła wszystkie dotychczasowe komunikaty Turn.

Jeśli rozgrywka nie jest jeszcze rozpoczęta, to wysłanie przez klienta komunikatu Join powoduje dodanie go do listy graczy. Serwer następnie rozsyła do wszystkich klientów komunikat AcceptedPlayer.

Graczom nadawane jest ID w kolejności podłączenia (tzn. odebrania komunikatu Join przez serwer). Dwoje graczy może mieć taką samą nazwę. Ponieważ klienci łączą się z serwerem po TCP, wiadomo który komunikat przychodzi od którego klienta.

Odłączenie gracza w trakcie rozgrywki powoduje tylko tyle, że jego robot przestaje się ruszać. Odłączenie klienta-gracza przed rozpoczęciem rozgrywki nie powoduje skreślenia go z listy graczy. Odłączenie klienta-obserwatora nie wpływa na działanie serwera.

2.7. Rozpoczęcie partii i zarządzanie podłączonymi klientami

Partia rozpoczyna się, gdy odpowiednio wielu graczy się zgłosi. Musi być dokładnie tylu graczy, ile jest wyspecyfikowane przy uruchomieniu serwera.

Inicjacja stanu gry przebiega następująco:

nr_tury = 0
zdarzenia = []

dla każdego gracza w kolejności id:
    pozycja_x_robota = random() % szerokość_planszy
    pozycja_y_robota = random() % wysokość_planszy
    
    dodaj zdarzenie `PlayerMoved` do listy
    
tyle razy ile wynosi parametr `initial_blocks`:
    pozycja_x_bloku = random() % szerokość_planszy
    pozycja_y_bloku = random() % wysokość_planszy
    
    dodaj zdarzenie `BlockPlaced` do listy
    
wyślij komunikat `Turn`

2.8. Przebieg partii

Zasady:

  • Nie ma ograniczenia na liczbę bloków i bomb.
  • Gracze nie mogą wchodzić na pole, które jest zablokowane. Mogą natomiast z niego zejść, jeśli znajdą się na nim, wskutek zablokowania go lub „odrodzenia" się na nim.
  • Gracze nie mogą wychodzić poza planszę.
  • Wielu graczy może zajmować to samo pole.
  • Bomby mogą zajmować to samo pole.
  • Gracze mogą położyć bombę, nawet jeśli stoją na zablokowanym polu (czyli na jednym polu może być blok, wielu graczy i wiele bomb)
  • Na danym polu może być maksymalnie jeden blok
zdarzenia = []

dla każdej bomby:
    zmniejsz jej licznik czasu o 1
    jeśli licznik wynosi 0:
        zaznacz, które bloki znikną w wyniku eksplozji
        zaznacz, które roboty zostaną zniszczone w wyniku eksplozji
        dodaj zdarzenie `BombExploded` do listy
        usuń bombę    
    
dla każdego gracza w kolejności id:
    jeśli robot nie został zniszczony:
        jeśli gracz wykonał ruch:
            obsłuż ruch gracza i dodaj odpowiednie zdarzenie do listy
    jeśli robot został zniszczony:
        pozycja_x_robota = random() % szerokość_planszy
        pozycja_y_robota = random() % wysokość_planszy
    
        dodaj zdarzenie `PlayerMoved` do listy
        
zwiększ nr_tury o 1

W wyniku eksplozji bomby zostają zniszczone wszystkie roboty w jej zasięgu oraz jedynie najbliższe bloki w jej zasięgu. Eksplozja bomby ma kształt krzyża o długości ramienia równej parametrowi explosion_radius. Jeśli robot stoi na bloku, który zostanie zniszczony w wyniku eksplozji, to taki robot również jest niszczony.

Intuicyjnie oznacza to, że można się schować za blokiem, ale położenie bloku pod sobą nie chroni przed eksplozją.

Przykłady:

@ - blok
A, B, C... - bomby
1, 2, 3... - gracze
x - eksplozja
.@2..
..1..
@@A.@
..@..
.....

Pola oznaczone jako eksplozja po wybuchu A z promieniem równym 2:

.@x..
..x..
@xxxx
..x..
.....

A zatem zniszczone zostaną 3 bloki i oba roboty.

Jeśli na polu jest bomba, blok i jacyś gracze, to wybuch bomby zniszczy blok i wszystkich graczy na tym polu stojących.

@@@@@
@@AB@
.@@@@

Jednoczesna eksplozja A i B z promieniem równym 2:

@@xx@
@xxxx
.@xx@

Po eksplozji:

@@..@
@....
.@..@

Eksplozja jednej bomby nie powoduje eksplozji bomb sąsiednich. Jeśli kilka bomb wybucha w jednej turze, to skutki eksplozji są sumą teoriomnogościową pojedynczych eksplozji rozpatrywanych osobno. W powyższym przykładzie widać że blok o współrzędnych (0, 1) nie został zniszczony.

2.9. Wykonywanie ruchu

Serwer przyjmuje informacje o ruchach graczy w następujący sposób: przez turn_duration milisekund oczekuje na informacje od graczy. Jeśli gracz w tym czasie nie wyśle odpowiedniej wiadomości, to w danej turze jego robot nic nie robi. Jeśli w tym czasie gracz wyśle więcej niż jedną wiadomość, to pod uwagę brana jest tylko ostatnia.

To serwer decyduje o tym, czy dany ruch jest dozwolony czy nie. Jeśli gracz stojący na krawędzi planszy wyśle komunikat, który spowodowałby wyjście robota poza planszę, to serwer komunikat ignoruje. Podobnie jeśli spróbuje wejść na zablokowane pole.

2.10. Kończenie rozgrywki

Po game_length turach serwer wysyła do wszystkich klientów wiadomość GameEnded i wraca do stanu Lobby. Klienci, którzy byli do tej pory graczami, przestają nimi być, ale oczywiście mogą się z powrotem zgłosić przy pomocy komunikatu Join. Wszystkie komunikaty otrzymane w czasie ostatniej tury rozgrywki są ignorowane.

2.11. Błędy w komunikacji

Co jeśli klient prześle komunikat o nieprawidłowym formacie? Czy należy wtedy uznać go za odłączonego? Tak, bo ponieważ protokół jest binarny i po napotkaniu jakichkolwiek nieprawidłowych danych nie da się dowiedzieć, od którego momentu dane z powrotem są prawidłowe, jedyne co można zrobić to odłączyć klienta.

3. Protokół komunikacyjny pomiędzy klientem a interfejsem użytkownika

Komunikacja z interfejsem odbywa się po UDP przy użyciu komunikatów serializowanych tak jak wyżej.

Klient wysyła do interfejsu graficznego następujące komunikaty:

enum DrawMessage {
    [0] Lobby {
        server_name: String,
        players_count: u8,
        size_x: u16,
        size_y: u16,
        game_length: u16,
        explosion_radius: u16,
        bomb_timer: u16,
        players: Game<PlayerId, Player>
    },
    [1] Game {
        server_name: String,
        size_x: u16,
        size_y: u16,
        game_length: u16,
        turn: u16,
        players: Game<PlayerId, Player>,
        players_positions: Game<PlayerId, Position>,
        blocks: List<Position>,
        bombs: List<Bomb>,
        explosions: List<Position>,
        scores: Game<PlayerId, Score>,
    },
}

Explosions w komunikacie Game to lista pozycji, na których robot by zginął, gdyby tam stał.

Klient powinien wysłać taki komunikat po każdej zmianie stanu (tzn. otrzymaniu wiadomości Turn jeśli rozgrywka jest w toku lub AcceptedPlayer jeśli rozgrywka się nie toczy).

Interfejs wysyła do klienta następujące komunikaty:

enum InputMessage {
    [0] PlaceBomb,
    [1] PlaceBlock,
    [2] Move { direction: Direction },
}

Są one wysyłane za każdym razem, gdy gracz naciśnie odpowiedni przycisk.

Można założyć, że komunikaty zmieszczą się w jednym datagramie UDP. Każdy komunikat wysyłany jest w osobnym datagramie.

4. Ustalenia dodatkowe

Program klienta w przypadku błędu połączenia z serwerem gry lub interfejsem użytkownika powinien się zakończyć z kodem wyjścia 1, uprzednio wypisawszy zrozumiały komunikat na standardowe wyjście błędów.

Program serwera powinien być odporny na sytuacje błędne, które dają szansę na kontynuowanie działania. Intencja jest taka, że serwer powinien móc być uruchomiony na stałe bez konieczności jego restartowania, np. w przypadku kłopotów komunikacyjnych, czasowej niedostępności sieci, zwykłych zmian jej konfiguracji itp.

Serwer nie musi obsługiwać więcej niż 25 podłączonych klientów (graczy + obserwatorów) jednocześnie.

Programy powinny umożliwiać komunikację zarówno przy użyciu IPv4, jak i IPv6.

Można korzystać z biblioteki Boost, w szczególności z modułu asio.

Rozwiązanie ma kompilować się i działać na serwerze students.

Rozwiązania należy kompilować z flagami -Wall -Wextra -Wconversion -Werror -O2.

Rozwiązania napisane w języku C++ powinny być kompilowane z flagą -std=gnu++20, a w języku C z flagą -std=gnu17 przy użyciu GCC 11.2 lub nowszego (na students w katalogu /opt/gcc-11.2/bin.

Rozwiązanie powinno być odpowiednio sformatowane (można użyć np. clang-format).

Dodatkowo polecamy używanie lintera (np. clang-tidy, który jest zintegrowany z CLionem) i/lub kompilowanie z flagą -fanalyzer.

About

C++ multithread client side of the multiplayer Bomberman game - uses both TCP and UDP protocols

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published