Wprowadzenie
Internet jest dziś wszędzie. Nie byłoby fajnie wykorzystać sieci przy pisaniu własnego programu/gry?
Jest na to prosty sposób: można wykorzystać moduł
SFML Network który daje prosty i wygodny dostęp do komunikacji między komputerami.
Na początku przedstawimy trochę teorii na temat działania Internetu(jeśli znasz się na tym możesz pominąć ten dział)
TCP, UDP i IP
Każdy komputer podpięty do sieci otrzymuje swój własny, unikatowy
adres IP (
Internet Protocol).
Zazwyczaj przedstawia się go w postaci np. "25.750.243.768".
Do komunikacji między komputerami wykorzystuje się najczęściej dwa protokoły:
TCP i
UDP.
Wszystkie one są bezpośrednio wspierane przez SFML.
Żeby połączyć dwa różne komputery używa się tzw.
gniazd (ang.
socket).
To porównanie pomoże ci zrozumieć działanie gniazd, połączeń i protokołów:
Wyobraź sobie dwa gniazdka w dwóch programach - to właśnie sa gniazda. Możemy je połączyć kablem - to jest połączenie internetowe.
Te dwa gniazdka mogą mieć różne rodzaje (mogą być takie jak u nas, lub takie jak w Wielkiej Brytanii),
Jednak muszą oba mieć ten sam rodzaj, eby można je było połączyć odpowiednim kablem - właśnie te rodzaje gniazdek to protokoły.
TCP
TCP (
Transmission Control Protocol) oparte jest na koncepcji
klient-serwer. Najpierw klient łączy się do serwera, po czym można wymieniać między nimi dane. Jest jeszcze jedno pojęcie związane z tym protokołem: jeśli serwer chce połączyć się z klientem, to
nasłuchuje (ang.
listen) na swoim porcie, czy jakiś komputer nie chce się z nim komunikować.
UDP
UDP (
User Datagram Protocol) zasadniczo rożni się od
TCP tym, że gniazdo TCP klienta
łączy się z gniazdem serwera, a w UDP komputer po prostu wysyła dane do innego komputera
bez nawiązania polączenia.
UDP jest zasadniczo szybsze od TCP, lecz niekiedy "gubi" dane, więc stosuje się go w transmisjach czasu rzeczywistego (konferencje wideo, itp.).
Jescze jedna funkcja UDP to możliwość wysyłania do kilku komputerów naraz.
Połączenie TCP
Tworzenie gniazda klienta
Adres serwera, do którego się chcemy połączyć możemy przedstawić jako klasę
sf::IpAddress która w konstruktorze przyjmuje
adres w postaci np. "10.204.87.74"
Do tworzenia gniazda klienta TCP używamy natomiast klasy
sf::TcpSocket.
sf::TcpSocket socket; sf::IpAddress ip = "adres.ip.serwera.do.ktorego.sie.chcesz.polaczyc";
unsigned int port = 54000 if( socket.connect( ip, port ) != sf::Socket::Done ) {
cerr << "Nie można połączyć się z " << ip.toString() << endl;
exit( 1 );
}
Pobieranie adresu lokalnego/zdalnego
Adres lokalny (ang.
local address) to adres naszego komputera.
Adres zdalny (ang.
remote address) to adres komputera, do którego jesteśmy podłączeni.
Gniazda TCP umożliwiają odczytanie obydwu tych adresów.
getLocalAddress() zwraca lokalne IP.
getLocalPort() zwraca lokalny port.
getRemoteAddress() zwraca zdalne IP.
getRemotePort() zwraca zdalny port.
Tworzenie gniazda serwera TCP
Do stworzenia gniazda serwera TCP potrzebna jest klasa
sf::TcpListener.
sf::TcpListener listener; unsigned int port = 54000; if( listener.listen( port ) != sf::Socket::Done ) {
cerr << "Nie mogę rozpocząć nasłuchiwania na porcie " << port << endl;
exit( 1 );
}
Następnie używamy metody
accept(), która oczekuje na klienta chętnego do nawiązania połączenia.
Kiedy takowy się znajdzie, inicjuje ona gniazdo podane przez referencję w argumencie, przez które możemy się z nim komunikować
while( )
{
sf::TcpSocket client; listener.accept( client );
}
Komunikacja między połączonymi gniazdami TCP
No dobra, mamy gotowe, połączone, gniazda klienta i serwera. Teraz można już się komunikować.
const int datasize = 100; char data[ 100 ] = "..."; if( socket.send( data, datasize ) != sf::Socket::Done ) {
cerr << "Nie można wysłać danych!\n";
exit( 1 );
}
unsigned int received; if( socket.receive( data, datasize, received ) != sf::Socket::Done ) {
cerr << "Nie można odebrać danych!\n";
exit( 1 );
}
else
cout << "Odebrano " << received << " bajtów\n";
Uwaga:
Wszystkie funkcje odbierania danych, oraz funkcja accept() są blokujące.
Oznacza to mniej więcej, że funkcja taka będzie czekać na dane do odebrania(w przypadku receive()) lub klienta(accept())
Zamykanie połączenia TCP
Żeby zamknąć połączenie wystarczy wywołać metodę
close()
Komunikacja za pomocą UDP
W UDP jest prościej, niż w TCP. Nie trzeba tworzyć klienta i serwera, akceptować połączeń, nasłuchiwać itd.
Taki jest mniej więcej schemat działania:
sf::UdpSocket sock; const int datasize = 100; char data[ datasize ] = "..."; sf::IpAddress ip( "adres.komputera.do.którego.chcesz.wysłać.dane" );
unsigned int port = 56000; if( sock.send( data, datasize, ip, port ) != sf::Socket::Done )
{
cerr << "Nie można wysłać danych!\n";
exit( 1 );
}
sf::IpAddress ip( "adres.komputera.od.którego.chcesz.odebrać.dane" );
unsigned int port = 56000; unsigned int senderport = 54000; unsigned int received;
sock.bind( port );
if( sock.receive( data, datasize, received, ip, senderport ) != sf::Socket::Done )
{
cerr << "Nie można odebrać danych!\n";
exit( 1 );
}
cout << "Odebrano bajtów: " << received << endl;
Problem blokujących gniazd
Wyobraź sobie, że piszesz serwer, który obsługuje naraz wiele klientów. Nie możesz używać metody
accept(). Dlaczego? Spójrzmy na przykład:
sf::TcpListener server; vector < sf::TcpSocket * > clients; while( true )
{
TcpSocket * tmp = new sf::TcpSocket;
server.accept( * tmp ); clients.push_back( tmp ); }
Uwaga!:
Zauważ, że gniazda przechowujemy w kontenerze wskaźników do nich (vector<TcpSocket*>). Powodem tego jest to, że przypisywanie gniazd do siebie jest zabronione (zadeklarowane jako prywatna składowa klasy sf::TcpSocket). Dlatego gniazda, które będą przechowywane w kontenerach należy tworzyć w pamięci wolnej
Przeanalizujmy ten kod: W głównej pętli tworzymy sobie gniazdo i akceptujemy. Czekaj!
Przecież metoda accept() oczekuje na klienta! A co, jeśli nikt nie będzie chciał się z nami połączyć?
accept() będzie czekało i blokowało program!
Co będzie z podłączonymi już klientami, które czekają, aż je obsłużymy?
Taki sam problem występowałby, gdybyśmy, podczas obsługiwania klienta wywołali
receive(), a klient nic by nam nie wysłał -
receive() czekało by na dane do odebrania
Musi być jakiś sposób, żeby wiedzieć, że ktoś się chce do nas podłączyć lub nam coś wysłać...
Owszem, SFML posiada klasę
sf::SocketSelector, która informuje nas, czy któreś z obserwowanych gniazd chce nam coś wysłać. Gniazda dodajemy za pomocą metody
add()sf::TcpListener server; vector < sf::TcpSocket * > clients; sf::SocketSelector sel; sel.add( server ); while( true )
{
if( sel.wait( sf::seconds( 2 ) ) {
if( sel.isReady( server ) ) {
TcpSocket * tmp = new sf::TcpSocket;
server.accept( * tmp ); clients.push_back( tmp ); sel.add( * tmp ); }
for( int i = 0; i < clients.size(); i++ ) {
if( sel.isReady( * clients[ i ] ) ) {
const int datasize = 100; char data[ datasize ]; unsigned int received; clients[ i ]->receive( data, datasize, received );
cout << "Odebrano " << received << " bajtów od " << clients[ i ]->getRemoteAddress() << endl;
}
}
}
Pakiety
Z TCP (i UDP) jest pewien problem: jeśli wyślesz np. 1kB danych to dotrą one (prawdopodobnie) podzielone na klka części.
Serwer odbierze pierwszą serię danych, a później nie będzie wiedział, czy ma oczekiwać na następną, czy to już wszystko.
Drugi problem to różnice między komputerami: kiedy wysyłasz dane do jakiejś maszyny, nie wiesz, czy ma ona np. ten sam rozmiar typu int, albo czy używa ona konwencji little-endian (bajty "bardziej znaczące" są na początku), czy big-endian (na końcu).
Obydwa te problemy można rozwiązać za pomocą klasy
sf::Packet oraz kilku typów o stałych rozmiarach.
sf::Packet
Ta klasa implementuje
pakiet - serię danych dodatkowo znającą swój rozmiar.
Jak odbierać dane za pomocą pakietów:
1. Tworzymy zmienną typu sf::Packet
2. Odbieramy dane za pomocą metody
receive()3. Wyciągamy dane z pakietu jak ze strumienia istream z biblioteki standardowej
sf::Packet pak; someSocket.receive( pak ); float x;
int y;
string z;
pak >> x >> y >> z;
Powinno się sprawdzać, czy odczyt danych powiódł się w taki sam sposób, jak robi się to ze standardowymi strumieniami:
if( pak >> x >> y >> z )
else
Pamiętaj, by odczytywać zmienne w takiej samej kolejności, w jakiej się je wysłało
Jak wysyłać dane z pomocą pakietów:
1. Tworzymy zmienną typu sf::Packet
2. Zapisujemy dane do pakietu jak do strumienia ostream z biblioteki standardowej
3. Wysyłamy dane za pomocą metody
send()sf::Packet pak; float x = 3.14f;
int y = 64;
string z = "hello!";
pak << x << y << z; someSocket.send( pak );
Typy o stałych rozmiarach
Jeśli nie jesteś pewny, że klient i serwer działają na takich samych maszynach, pownieneś wysyłać zmienne o jasno określonej wielkości: ('ze znakiem' oznacza, ze liczba może być ujemna)