• Najnowsze pytania
  • Bez odpowiedzi
  • Zadaj pytanie
  • Kategorie
  • Tagi
  • Zdobyte punkty
  • Ekipa ninja
  • IRC
  • FAQ
  • Regulamin
  • Książki warte uwagi

Pętla stałokrokowa gry

VPS Starter Arubacloud
+4 głosów
1,778 wizyt
pytanie zadane 30 maja 2016 w C i C++ przez niezalogowany

Cześć.

Chciałbym trochę porozmawiać o pętlach stałokrokowych. Wiem, że taki temat już kiedyś był http://forum.pasja-informatyki.pl/144565/petla-stalokrokowa-tworzenie-gier ale tam autor pytania w ogóle nie rozumiał pętli stałokrokowej, a mi chodzi bardziej o różne typy pętli. Może od początku:

while(game)
{
    update();
    display();
}

Jak wszyscy wiedzą, taka pętla jest niepoprawna, ponieważ na szybkim komputerze postać będzie się poruszać szybko (albo po prostu pojawi się na drugim końcu mapy,), a na wolnym komputerze postać będzie się strasznie wlokła. W sumie do niedawna myślałem, że pętla, która teraz pokażę, w zupełności wystarczy (na przykładzie SFML-a, ale wszystkie nazwy są intuicyjne):

Clock clock;
clock.restart();
Time elapsedTime=Time::Zero;
Time updateRate=seconds(1.f/60.f); //dla 60 FPS-ów

while(game)
{
    elapsedTime=clock.restart();
    if(elapsedTime>updateRate)
    {
        elapsedTime-=updateRate;
        
        update();
    }
    display();
}

Otóż taka pętla (według mnie) powinna już działać normalnie na każdym komputerze. No ale jednak tak nie jest. Gdy przesłałem koledze próbkę gry Pong, to na jego komputerze piłka latała bardzo szybko, to samo z paletką, którą się ją odbijało. Kiedy indziej sobie sprawdziłem na innym komputerze, ta sama sytuacja, tylko że tym razem na odwrót: wszystko było jakby w zwolnionym tempie. No i w sumie się zgadzało, bo komputer kolegi jest bardzo dobry, a drugi komputer jest tani, czyli słabszy. No ale czemu taka pętla i tak nie działa?

No więc zainteresowałem się sposobami na pętlę stałokrokową. Mam dwa źródła:

Kiedyś czytałem drugi link, a pierwszy znalazłem niedawno. W pierwszym są cztery typy pętli, z czego najlepszy ponoć jest ostatni, czyli "Stała prędkość gry niezależna od zmiennej wartości FPS":

const int TICKS_PER_SECOND=25;
const int SKIP_TICKS=1000/TICKS_PER_SECOND;
const int MAX_FRAMESKIP=5;
 
DWORD next_game_tick=GetTickCount();
int loops;
float interpolation;
 
bool game_is_running=true;
while(game_is_running)
{
    loops=0;
    while( GetTickCount()>next_game_tick&&loops<MAX_FRAMESKIP)
    {
        update_game();
        next_game_tick+=SKIP_TICKS;
        loops++;
    }
    interpolation=float( GetTickCount()+SKIP_TICKS-next_game_tick)/float(SKIP_TICKS);
    display_game(interpolation);
}

Najbardziej mnie zaciekawiła ta "interpolacja", ponieważ gdy sprawdzałem mojego Ponga na dwóch różnych komputerach (tryb online multiplayer), to ruch drugiego gracza był przerywany, jakby ścinało. Przez to, że pobieranie danych trwa jednak wolniej niż aktualizowanie gry (więc w skrócie, interpolacja jest to przewidzenie gdzie powinien być dany obiekt w danej klatce, dzięki czemu gra jest płynniejsza, wydaje się, że ma się więcej FPS-ów). Tylko że nadal niezbyt rozumiem ogólną pętlę. Między innymi:

  • czemu dzieli się akurat 1000 przez żądaną liczbę FPS-ów?
  • na co jest zmienna loops?
  • dlaczego MAX_FRAMESKIP wynosi akurat 5?
  • no i ogólne działanie tej pętli.

Jeśli nie uważacie tej pętli za najlepszą (mi się podoba ten pomysł z interpolacją, szczególnie w grach online), to czy ta z drugiego źródła będzie lepsza?

float dt=0.0f; //czas od ostatniej aktualizacji
float lastUpdateTime=GetCurrentTime(); //czas ostatniej aktualizacji
                                       //przykladowa funkcja GetCurrentTime() pobiera
                                       //nam od systemu aktualny czas w sekundach
float accumulator=0.0f;
const float TIME_STEP=0.03; //krok czasowy, a zarazem czas trwania ramki
                            //fizyki w sekundach; tutaj 30 milisekund, czyli
                            //ok. 30 aktualizacji na sekundę
const float MAX_ACCUMULATED_TIME=1.0; //maksymalny czas zgromadzony w pojedynczym
                                      //obiegu petli glownej
while(true)
{
    dt=GetCurrentTime()-lastUpdateTime; //obliczenie czasu od ostatniej klatki
    lastUpdate+=dt; //podmiana
    dt=std::max(0,dt); //upewniamy sie, ze dt >= 0
    accumulator+=dt;
    accumulator=clamp(accumulator,0,MAX_ACCUMULATED_TIME); //zapobiegamy
                                                           //zbyt duzej ilosci aktualizacji w danym obiegu
                                                           //petli glownej
    GrabInput(); //<-- zbieranie wejscia z klawiatury, myszki, sieci, itp.
    while(accumulator>TIME_STEP)
    {
        UpdateGame(TIME_STEP); //<-- aktualizacja fizyki i logiki gry
        accumulator-=TIME_STEP;
    }
    RenderGame(); //<-- wyswietlenie aktualnego stanu na ekranie
}

Ta pętla jest bardzo podobna do tej, która niby miała być normalna i powinna działać (czyli ta pętla stałokrokowa, którą pokazałem jako pierwszą). Więc w czym jest ona lepsza? A może to będzie to samo? Nie rozumiem, dlaczego na jednym komputerze taka pętla jest szybsza, a na innym wolniejsza (niby jest to wytłumaczone w pierwszym źródle, "Prędkość gry bazująca na zmiennej wartości wyświetlanych klatek - FPS").

Więc prosiłbym o ogólne wytłumaczenie dlaczego taka pętla jednak nie działa i jeśli lepiej używać innej pętli, to poproszę o wytłumaczenie jej działania. Dziękuję bardzo.

komentarz 30 maja 2016 przez Ehlert Ekspert (212,630 p.)

Bardzo ciekawe pytanie. Plus ode mnie  i również oczekuje na odpowiedź yes

komentarz 30 maja 2016 przez niezalogowany
Miło, że nie tylko ja się czegoś nauczę :) Mam nadzieję, że ktoś zna odpowiedź na to pytanie.

3 odpowiedzi

+1 głos
odpowiedź 30 maja 2016 przez Patrycjerz Mędrzec (192,340 p.)

Po pierwsze, nie rozpisuj się tak rozlegle, bo nikomu się nie chce tego wszystkiego czytać wink

Po drugie, do prostych gier warto użyć metody setFramerateLimit z klasy sf::Window, która powoduje ustawienie pętli stałokrokowej na ilość klatek podaną w argumencie.

Po trzecie, twój przykład pętli nie ma racji bytu, gdyż czas musi być wystarczająco duży, aby został spełniony warunek z funkcją update - w skrócie, nie są zliczane małe odstępy czasu. Lepiej zastosować metodę getElapsedTime i resetować zegar dopiero po zliczeniu odpowiedniego czasu (jeśli nie chcesz używać setFramerateLimit):

const float FPS = 100.0f;
sf::RenderWindow window;
sf::Clock clock;
sf::Time time;
while(window.isOpen())
{
	if(time.asSeconds() > (1.0f / FPS))
	{
		update();
		clock.restart();
		time = sf::Time::Zero;
	}
	display();
	time = clock.getElapsedTime();
}

Po czwarte, nie zawracaj sobie głowy jakąś interpolacją i innymi bajerami - przyjdzie na to czas, gdy jedna z twoich produkcji będzie tego wymagać. Do prostych gier spokojnie wystarczy pętla stałokrokowa.

komentarz 30 maja 2016 przez niezalogowany
Sory, ale chciałem szczegółowo omówić problem ;)

Oczywiście używam sf::setFramerateLimit(), tylko że o tym nie wspomniałem (jest to zastąpienie pierwszej pętli z pierwszego źródła, w którym używało się funkcji Sleep(), a sf::setFramerateLimit() ponoć działa na tej samej zasadzie).

No właśnie tak coś myślałem, że coś mi nie pasowało z tym elapsedTime=clock.restart(). Wcześniej używałem getElapsedTime() w mniej więcej takim ułożeniu kodu, ale później szukałem właśnie czegoś o pętli stałokrokowej i trafiłem na stronę, na której była taka pętla. Niby troszkę inna, ale wygląda bardzo podobnie. No ale OK, wypróbuję starą pętlę (dopiero jutro) i zobaczę jak sytuacja będzie wyglądała na innych komputerach. Jeśli będzie działać, to temat chyba uznam za zamknięty, aczkilwiek jeszcze będę się uczył o tych różnych pętlach, bo to ciekawa sprawa.

Tak, wiem, trochę wydziwianie z tymi pętlami. Myślałem, że jest zmiennokrokowa i stałokrokowa, a tu proszę, tyle różnych rodzajów. Na pewno wykorzystam tą "interpolację", jak to nazwano, bo może mi się przydać, ale jeśli pętla będzie działać to już będzie super.
komentarz 30 maja 2016 przez niezalogowany

A jeszcze takie pytanko, czy można by jeszcze dodać taką małą linijkę w kodzie:

const float FPS = 100.0f;
sf::RenderWindow window;
sf::Clock clock;
sf::Time time;
while(window.isOpen())
{
    if(time.asSeconds() > (1.0f / FPS))
    {
        time-=(1.0f/FPS); //żeby było troszkę dokładniej, równiejsze odstępy czasu
        update();
        clock.restart();
        //time = sf::Time::Zero; <--- tą sobie usuniemy
    }
    display();
    time = clock.getElapsedTime();
}

Czy tak mogłoby być? :)

komentarz 30 maja 2016 przez Patrycjerz Mędrzec (192,340 p.)

Nie ma to trochę sensu, gdyż:

  • Chcesz odjąć od obiektu sf::Time wartość float - zastosuj funkcję seconds.
  • Po wywołaniu funkcji display obiekt time zostaje nadpisany.
komentarz 30 maja 2016 przez niezalogowany
Dobra, to odejmowanie rzeczywiście nie ma sensu. OK, zrobię taką pętlę jaka jest w tym ostatnim przykładzie http://temporal.pr0.pl/devblog/download/arts/fixed_step/fixed_step.pdf Zobaczymy jak wyjdzie, ale wydaje mi się, że już powinno być dobrze.

Dzięki bardzo za pomoc, kod sprawdzę niestety kiedy indziej, jutro albo pojutrze, więc póki co, to temat wstrzymuję, a jak już coś zrobię to napiszę ;)
komentarz 31 maja 2016 przez niezalogowany
OK, zrobiłem parę rzeczy.

Pierwsze co zrobiłem, to zamieniłem moją brzydką pętlę, na twoją, już działającą. Niestety, ale działającą tylko na moim komputerze. Na innych komputerach gra się zachowywała inaczej, na wolniejszym kompie wolniej, a na szybszym szybciej. Potem przerobiłem pętlę na tą z artykułu w PDF-ie http://temporal.pr0.pl/devblog/download/arts/fixed_step/fixed_step.pdf No i jest dobra i zła wiadomość. Dobra jest taka, że ta pętla już działa na każdym sprzęcie tak samo, czy to szybki czy wolny. Zła wiadomość jest taka, że gra straciła na swej atrakcyjności, bo ścina. Podczas ruchu paletki i piłeczki widać, że gra ścina, nie jest płynna, tak jak by piłeczka się lekko teleportowała, po prostu ścina, w przeciwności do pętli, jaką pokazał Patrycjerz (której chyba nie będę używał, bo jednak nie rozwiązała pierwszego problemu), w której gra jest bardzo płynna. Próbowałem zwiększyć (a nawet zmniejszyć) ilość FPS-ów (zmienna TIME_STEP), włączałem i wyłączałem funkcje setFramerateLimit(TIME_STEP) i setVerticalSyncEnabled(), ale niestety było to samo, a nawet gorzej. Sam jeszcze pomyślę nad tym problemem, bo jednak tylko ja znam kod cały kod, więc jeszcze trochę pokombinuję (może tą pętlę źle zaimplementowałem, musiałem ją troszkę przerobić na potrzeby SFML-a, więc w różnych miejscach dam cout'y, żeby zobaczyć jak się miewają zmienne dotyczące pętli i czasu), ale jeśli ktoś ma pomysł jakby to można było naprawić, to byłoby naprawdę fajnie :)
+1 głos
odpowiedź 30 maja 2016 przez draghan VIP (106,230 p.)

Zacznijmy od Twojej pętli, która nie do końca działa.

while(game)
{
    elapsedTime=clock.restart();
    if(elapsedTime>updateRate)
    {
        elapsedTime-=updateRate;
         
        update();
    }
    display();
}

Możesz ją poprawić na dwa sposoby. Albo if zamieniasz na while, co już niżej napisał Criss, albo przy wykonaniu update zerujesz elapsedTime.

czemu dzieli się akurat 1000 przez żądaną liczbę FPS-ów?

Zapewne dlatego, żeby uzyskać rozdzielczość w milisekundach.

na co jest zmienna loops?

dlaczego MAX_FRAMESKIP wynosi akurat 5?

Liczy, ile razy wykonała się funkcja update_game(), po to aby ograniczyć kolejne wykonania tej funkcji do MAX_FRAMESKIP, która wynosi 5, ponieważ autor założył że update może wykonać się maksimum 5 razy bez renderowania.

no i ogólne działanie tej pętl

Eee... No jest ona dość dokładnie wyjaśniona pod linkiem, który wstawiłeś.

Pętla, którą pokazujesz na koniec, jest wystarczająco dobra. ;) Wykorzystanie interpolacji to, według mnie, niepotrzebne komplikowanie, dające efekt który nie pokrywa się z nakładem pracy potrzebnym do jego uzyskania. Zauważ, że funkcja predykcji dla każdej gry będzie wyglądała inaczej.

komentarz 30 maja 2016 przez niezalogowany
Dzięki dwóm pierwszym odpowiedziom trochę rzeczy się wyjaśniło, między innymi to, że zamiast while()-a ma if()-a, albo to, że ogólnie moja poprzednia pętla była źle skonstruowana.

O, to jest bardzo możliwe z tymi milisekundami. No, coś w tym jest, jedna tysięczna sekundy to milisekunda (czy mi się pomyliło? :)).

Aha, czyli że to jest taki jakby ogranicznik, żeby kiedyś w końcu się ta gra zrenderowała.

Tak, też mi się tak wydaje :) Jest chyba najklarowniejsza. A co do interpolacji, to takiej grze 2D, w której ilość FPS-ów przekracza 250 (nagrywając Frapsem sobie to sprawdziłem), to może rzeczywiście nie mieć sensu takie ulepszanie. Ale jak już mówiłem, takie coś przydałoby się w grze online. Pobieranie danych nie jest zsynchronizowane z aktualizowaniem gry, przez co wszystko "ścina". Czy tak, czy tak, tej interpolacji używać nie będę, ale to jest po prostu fajny pomysł. Ale jeśli chodzi o grę offline, w której ma się ze 3 ruszające się obiekty... To nie trzeba tak kombinować :)

Dzięki bardzo za wytłumaczenie tej pętli :)
komentarz 30 maja 2016 przez criss Mędrzec (172,590 p.)

Co do tych milisekund - tam autor dzieli 1000 / FPS, nie FPS / 1000.

Co do multiplayera, to gówno sie znam, ale wydaje mi sie, że wymienianie sie danymi (i czekanie na nie) po sieci w tym samym wątku w którym działa logika gry to poroniony pomysł i nie ma szans żeby to kiedykolwiek działało dobrze.

komentarz 30 maja 2016 przez niezalogowany
Hm, no właśnie... W ogóle dziwny jest ten kod, a autor w ogóle nic nie tłumaczył :/

Ach, oczywiście, że zrobiłem to w osobnym wątku :D Tylko że czy tak, czy tak, pobieranie danych nie jest robione tyle razy co aktualizowanie gry. Wtedy, gdy pobieramy np. pozycję jakiegoś obiektu, to tymczasem on się przesuwa o kolejną wartość i zanim zaczniemy pobierać drugi raz, to on znowu się przemieści i tak dalej. Więc to tak jest :/
komentarz 30 maja 2016 przez draghan VIP (106,230 p.)

Co do tych milisekund - tam autor dzieli 1000 / FPS, nie FPS / 1000.

No okej. Załóżmy, że chcesz mieć 20 FPS. A zatem:

1000/20 = 50

...czyli renderowanie ma następować po upływie 50 ms.

komentarz 30 maja 2016 przez niezalogowany
O, proszę, znowu wszystko jasne :)
komentarz 31 maja 2016 przez niezalogowany
edycja 31 maja 2016
OK, zrobiłem parę rzeczy.

Pierwsze co zrobiłem, to zamieniłem moją brzydką pętlę, na tą od Patrycjerza, już działającą. Niestety, ale działającą tylko na moim komputerze. Na innych komputerach gra się zachowywała inaczej, na wolniejszym kompie wolniej, a na szybszym szybciej. Potem przerobiłem pętlę na tą z artykułu w PDF-ie http://temporal.pr0.pl/devblog/download/arts/fixed_step/fixed_step.pdf No i jest dobra i zła wiadomość. Dobra jest taka, że ta pętla już działa na każdym sprzęcie tak samo, czy to szybki czy wolny. Zła wiadomość jest taka, że gra straciła na swej atrakcyjności, bo ścina. Podczas ruchu paletki i piłeczki widać, że gra ścina, nie jest płynna, tak jak by piłeczka się lekko teleportowała, po prostu ścina, w przeciwności do pętli, jaką pokazał Patrycjerz (której chyba nie będę używał, bo jednak nie rozwiązała pierwszego problemu), w której gra jest bardzo płynna. Próbowałem zwiększyć (a nawet zmniejszyć) ilość FPS-ów (zmienna TIME_STEP), włączałem i wyłączałem funkcje setFramerateLimit(TIME_STEP) i setVerticalSyncEnabled(), ale niestety było to samo, a nawet gorzej. Sam jeszcze pomyślę nad tym problemem, bo jednak tylko ja znam kod cały kod, więc jeszcze trochę pokombinuję (może tą pętlę źle zaimplementowałem, musiałem ją troszkę przerobić na potrzeby SFML-a, więc w różnych miejscach dam cout'y, żeby zobaczyć jak się miewają zmienne dotyczące pętli i czasu), ale jeśli ktoś ma pomysł jakby to można było naprawić, to byłoby naprawdę fajnie :)
0 głosów
odpowiedź 30 maja 2016 przez criss Mędrzec (172,590 p.)

Ta pokazana przez ciebie pętla jakoś mi sie nie zgadza. Zamiast if(elapsedTime > updateRate) powinien być tam while. Inaczej nie bardzo ma to sens.

Drugiej pętli nie rozumiem i zadaje sobie podobne pytania jak ty..

W ostatniej pętli chodzi o rozdzielenie renderowania i fizyki. Masz ustawioną stałą dt fizyki (tutaj 0.03s). Dzięki takiej pętli fizyka wykona się zawsze tyle razy ile sobie wymyśliłeś niezależnie od renderowania (nawet jeśli jest wyłączony vsync). 

Za każdym razem do akumulatora jest dodawany czas renderowania. Następnie pętla fizyki w kolejnych krokach "konsumuje" sobie ten czas aż dojdzie do momentu kiedy czas w akumulatorze będzie mniejszy od 0.03 (stałej dt fizyki). To co zostanie w akumulatorze przechodzi do kolejnej iteracji, zostaje sumowane z czasem renderowania kolejnej klatki i ponownie konsumowane przez pętle fizyki. 

Poczytaj: http://gafferongames.com/game-physics/fix-your-timestep/

Co do ostatniej pętli w artykule (tam masz też wytłumaczoną tą interpolacje): State to jest pozycja, prędkość, zwrot itd...

komentarz 30 maja 2016 przez Patrycjerz Mędrzec (192,340 p.)
Zauważ, że w podanej przez ciebie pętli istnieje ogranicznik, który uniemożliwia nieustanny spadek liczby FPS - w takim przypadku ma to sens.
komentarz 30 maja 2016 przez niezalogowany
O właśnie, zmienna (w sumie to stała) MAX_ACCUMULATED_TIME i funkcja clamp(). Czyli, że tamten kod jest git :) OK, jak na dzisiaj temat zamykam, bo trzeba się dobrze wyspać ;) Dobranoc.
1
komentarz 30 maja 2016 przez criss Mędrzec (172,590 p.)
@Patrycjesz nie wiem co ty właśnie zrobiłeś, ale to logiczne że w tym twoim kodzie każda klatka będzie trwala co raz dluzej bo co klatke zwiekszasz time, co z kolei powoduje, że więcej razy będzie się wywoływać update. Nie wiem co chciałeś tym udowodnić, ale to nie tak.
komentarz 31 maja 2016 przez niezalogowany
Ale potem dałem linka do właściwego kodu i chyba już wiadomo jak to działa ;)
komentarz 31 maja 2016 przez niezalogowany
edycja 31 maja 2016
OK, zrobiłem parę rzeczy.

Pierwsze co zrobiłem, to zamieniłem moją brzydką pętlę, na tą od Patrycjerza, już działającą. Niestety, ale działającą tylko na moim komputerze. Na innych komputerach gra się zachowywała inaczej, na wolniejszym kompie wolniej, a na szybszym szybciej. Potem przerobiłem pętlę na tą z artykułu w PDF-ie http://temporal.pr0.pl/devblog/download/arts/fixed_step/fixed_step.pdf No i jest dobra i zła wiadomość. Dobra jest taka, że ta pętla już działa na każdym sprzęcie tak samo, czy to szybki czy wolny. Zła wiadomość jest taka, że gra straciła na swej atrakcyjności, bo ścina. Podczas ruchu paletki i piłeczki widać, że gra ścina, nie jest płynna, tak jak by piłeczka się lekko teleportowała, po prostu ścina, w przeciwności do pętli, jaką pokazał Patrycjerz (której chyba nie będę używał, bo jednak nie rozwiązała pierwszego problemu), w której gra jest bardzo płynna. Próbowałem zwiększyć (a nawet zmniejszyć) ilość FPS-ów (zmienna TIME_STEP), włączałem i wyłączałem funkcje setFramerateLimit(TIME_STEP) i setVerticalSyncEnabled(), ale niestety było to samo, a nawet gorzej. Sam jeszcze pomyślę nad tym problemem, bo jednak tylko ja znam kod cały kod, więc jeszcze trochę pokombinuję (może tą pętlę źle zaimplementowałem, musiałem ją troszkę przerobić na potrzeby SFML-a, więc w różnych miejscach dam cout'y, żeby zobaczyć jak się miewają zmienne dotyczące pętli i czasu), ale jeśli ktoś ma pomysł jakby to można było naprawić, to byłoby naprawdę fajnie :)

Podobne pytania

0 głosów
2 odpowiedzi 396 wizyt
pytanie zadane 10 sierpnia 2017 w C i C++ przez WireNess Stary wyjadacz (11,240 p.)
+1 głos
2 odpowiedzi 824 wizyt
pytanie zadane 26 maja 2016 w C i C++ przez Kyoya Początkujący (260 p.)

92,455 zapytań

141,263 odpowiedzi

319,099 komentarzy

61,854 pasjonatów

Motyw:

Akcja Pajacyk

Pajacyk od wielu lat dożywia dzieci. Pomóż klikając w zielony brzuszek na stronie. Dziękujemy! ♡

Oto polecana książka warta uwagi.
Pełną listę książek znajdziesz tutaj.

Akademia Sekuraka

Akademia Sekuraka 2024 zapewnia dostęp do minimum 15 szkoleń online z bezpieczeństwa IT oraz dostęp także do materiałów z edycji Sekurak Academy z roku 2023!

Przy zakupie możecie skorzystać z kodu: pasja-akademia - użyjcie go w koszyku, a uzyskacie rabat -30% na bilety w wersji "Standard"! Więcej informacji na temat akademii 2024 znajdziecie tutaj. Dziękujemy ekipie Sekuraka za taką fajną zniżkę dla wszystkich Pasjonatów!

Akademia Sekuraka

Niedawno wystartował dodruk tej świetnej, rozchwytywanej książki (około 940 stron). Mamy dla Was kod: pasja (wpiszcie go w koszyku), dzięki któremu otrzymujemy 10% zniżki - dziękujemy zaprzyjaźnionej ekipie Sekuraka za taki bonus dla Pasjonatów! Książka to pierwszy tom z serii o ITsec, który łagodnie wprowadzi w świat bezpieczeństwa IT każdą osobę - warto, polecamy!

...