Pokazywanie postów oznaczonych etykietą Programowanie. Pokaż wszystkie posty
Pokazywanie postów oznaczonych etykietą Programowanie. Pokaż wszystkie posty

niedziela, 1 kwietnia 2018

Sieci neuronowe w rozwiniętej grze.

Następna wersja gry pojawi się w następnym tygodniu.

Jakiś czas temu pisałem o walkach sztucznej inteligencji przeciwko sztucznej inteligencji i o tym jak wspomagają proces tworzenia gry:
http://tokenbattle.blogspot.com/2017/08/ai-vs-ai-i-statystyki.html

Myślę że minęło wystarczająco dużo czasu aby do tematu powrócić i go rozwinąć.

Sieć neuronowa


W zwykłej sieci neuronowej zasady jest proste: na początku zbierane są dane testowe, a potem tworzony jest graf, którego poszczególne krawędzie mają analizować odpowiadające im informacje z danych testowych. Taki graf jest wypełniany losowymi lub określonymi wagami, a następnie dane testowe są przez niego przepuszczane. Z każdą iteracją wagi grafu są losowo lekko modyfikowane i sprawdzane jest, czy taka zmodyfikowana sieć lepiej przewiduje wyniki. Jeśli tak, to loswe zmiany są zapisywane, w przeciwnym razie przywracane są poprzednie wartości.

 Ale jest parę rzeczy które mój algorytm odróżnia od typowych sieci neuronowych. Po pierwsze - wynik nie jest określany na podstawie sieci neuronowej, lecz na podstawie wyniku AI vs AI. Co więcej: to dane testowe są dobierane losowo, wykorzystując dane z sieci neuronowej, a nie na odwrót. Można by rzecz, że jest to taka odwrócona sięc neuronowa, ale w gruncie rzeczy główna myśl jest taka sama: utworzyć grafy danych które będą ciągle modyfikowały same siebie w sposób lekko losowy do momentu aż uzyska się satysfakcjonujące wyniki.

Nie jestem więc pewny, czy nazywanie mojego tworu siecią neuronową jest poprawne, ale póki co będę go tak nazywał.


Cykliczna siła kart



Cykliczne zmiany w sile kart następują wtedy, gdy jakaś strategia jest bardzo silna, ale istnieje do niej mocna kontr-strategia. W takiej sytuacji taka silna strategia najpierw zyskuje na popularności, a gdy stanie się wystarczająco popularna zwiększa się siła jej kontr-strategii, co zmniejsza siłę i popularność kontrowanej strategii, a gdy kontrowana strategia przestaje być grana, kontr-strategia także zaczyna tracić na popularności. Gdy obie strategie przestaną być popularne, silna strategia znowu zaczyna być doceniana przez AI i koło się zatacza.

Świadomość takiej cykliczności jest bardzo ważna, ponieważ dzięki niej wiadomo, że nie można oceniać siły kart na podstawie samego wyniku końcowego - wyniki z sieci neuronowej powinny być przeglądane w miarę regularnie. Jeśli na wyniki spojrzymy w nieodpowiednim momencie może się okazać, że strategia którą uznaliśmy za zbyt silną okałaby się słaba po lekkim przesunięciu cyklu, a osłabienie przez nas tej strategii pogłębiłoby ten efekt.

Samo regularne przeglądanie danych nie rozwiązuje problemu. Może się bowiem okazać, że dane będą przez nas analizowane co pełen cykl. Innymi słowy może się okazać, że strategie które przy każdej naszej analizie uznajemy za zbyt silne w rzeczywistości są silne tylko przez 20% czasu.

Większa liczba kart



Liczba kart dostępnych w grze zwiększa się z każdą aktualizacją. Im więcej kart, tym więcej różnych zestawów kart można ułożyć. Przekłada się to na to, że AI musi przetestować większą liczbę opcji aby dać rzetelny wynik na temat tego, czy dana karta jest odpowiednio mocna. Gdy kart było niewiele, po paru godzinach widoczna już była przybliżona siła kart, a po paru dniach wyniki były raczej ustabilizowane, chyba że siła kart zmieniała się cyklicznie.

Obecnie liczba kart jest na tyle duża, że nawet tydzień nie jest wystarczająco długim czasem na ustabilizowanie się sieci i ten stan jest teraz czymś, do czego mogę się jedynie zbliżyć (biorąc pod uwagę tempo wypuszczania nowych kart).

Jeśli jakaś zrównoważona karta wymaga bardzo dużego zgrania z innymi kartami aby była skuteczna, to sieć przeważnie tego nie wyłapuje, bo po prostu działa za wolno. Czy to oznacza, że sieć neuronowa stała się bezużyteczna? Nie. Jeśli jakaś karta jest mocna i jednocześnie uniwersalna, to sieć neuronowa relatywnie szybko wskazuje jej dużą siłą i informację o tym mamy już po godzinie lub kilku. Jeśli zaś karta jest mocna ale wymaga synergii do działania, to zwykle zostaje to wyłapane po kilku godzinach, choć czasem trzeba na to czekać tydzień.

Warto też dodać że liczba kart nie tylko zwiększa liczbę możliwych do utworzenia zestawów kart, ale także zwiększa ilość informacji które AI powinno sprawdzać w trakcie gry wykonując ruch. Przykładowo jeśli nowy żeton wchodzi w interakcję z pobliskimi żetonami, AI w każdym swoim ruchu musi zacząć sprawdzać czy taka interakcja istnieje.

Dlaczego sieć wyłapuje synergie?



Zwykle gdy do sieci zostają wpuszczone nowe karty, muszą one się mierzyć ze strategiami które są bardzo dopracowane. AI potrafi określić jakie stare karty dobrze współpracują z innymi starymi kartami, więc talie są naprawdę zgrane. Niestety AI nie posiada takich informacji o nowych kartach, więc dobiera je w niemal całkowicie losowy sposób. Sprawia to, że nowe karty bardzo szybko zyskują niskie win ratio, nawet jeśli kilka kart z losowych zestawów było całkiem nieźle zgranych.

Więc po co to komu? Nawet jeśli nowe karty przegrywają większość gier, to niektóre pary kart będą miały mniejsze win ratio niż pozostałe. Powiedzmy że jedna para kart ma 46% win ratio, a inna 47%. Obie są słabe, ale ta drobna różnica sprawia, że słabsza para będzie wybierana rzadziej. Z biegiem czasu coraz więcej par kart jest niemal ignorowanych, co sprawia że pozostałe są wybierane częściej. Po pewnym czasie nowe karty będą dobierane głównie do kart które się z nimi zgrywają, a wtedy ich win ratio nie będzie już spadać i może nawet wzrośnie.

Dosyć często się zdarza, że win ratio nowych kart najpierw spada do 40%. a potem wzrasta aż do 53% i później utrzymuje się już na tym poziomie.


Uszkodzenia sieci neuronowych

Jeśli jakaś karta jest bardzo mocna, zaczyna dominować i wymusza zagrywanie kontr-synergii i doprowadza do cyklicznych zmian sił. Gorzej jest gdy jakaś karta okaże się absurdalnie mocna - wtedy jest na tyle silna, że nawet jeśli może być skontrowana przez popularną strategię to warto ją mieć na wypadek gdyby przeciwnik kontry nie posiadał. To sprawia, że ani ta silna strategia ani jej kontra nie tracą na popularności, czyli nie zachodzi cykl.

Brak cyklu jest tutaj zgubny, ponieważ niektóre karty zaczynają przegrywać zbyt często do tego stopnia, że nawet synergie między nimi zostają uznawane za zbyt słabe aby w ogóle brać takie karty do gry. O ile odrzucenie najsłabszych par ma pozytywny wpływ na sieć neuronową, to odrzucenie najlepszych par kompletnie niszczy użyteczność jej fragmentów. Zniszczenia które mogą nastąpić w parę godzin mogą być potem naprawiane kilka razy dłużej, a czasem nawet mogą minąć tygodnie lub miesiące nim do tego dojdzie.

Po zauważeniu takich zniszczeń trzeba zrozumieć ich przyczynę i odpowiednio zmodyfikować grę, np. poprzez osłabienie lub usunięcie tych kart. Ale zniszczenia pozostają. Aby im zapobiec warto jest robić backup'y. Nawet jeśli cofając się o kilka godzin bezpowrotnie tracimy ten czas, to ostatecznie zyskujemy tym, że nie będziemy musieli tracić większej ilości czasu na naprawienie tego błędu.

Sieć neuronową można też próbować naprawiać ręcznie, ale różne karty często potrzebują indywidualnego podejścia do ich naprawy. Przykładowo możemy zwiększyć win ratio każdej synergii danej karty o 3 punkty procentowe, ale jeśli ta karta współgra dobrze tylko z kilkoma kartami, to sprawimy że będzie częściej grana z kartami z którymi się nie zgrywa, czyli w rzeczywistości jedynie pogorszymy sytuację zamiast ją naprawić.

poniedziałek, 28 sierpnia 2017

AI vs AI i statystyki

Postanowiłem przekodować nieco aplikację abym mógł uruchomić grę AI vs AI z pominięciem tego co widzimy na kliencie, czyli z pominięciem wszystkich animacji itp. Z perwspektywy programowania taka funkcjonalność ma 3 następujące zalety:
- Pozwala wykryć błędy zwązane z wywoływaniem funkcji nie na tym sprzęcie co trzeba (przykładowo okazało się, że niektóre fragmenty kodu były wywoływane zarówno na serwerze jak i kliencie, mimo iż powinny być wywoływane tylko na kliencie),
- Przy wielokrotnym uruchomieniu gry AI vs AI pozwala szybko wykryć błędy które występują w bardzo specyficznych sytuacjach.
- Pozwala sprawdzić optymalność kodu, czyli ile gier na sekundę byłby w stanie utrzymać serwer (gdyby był moim laptopem).

Statystyki


Po załataniu kilku błędów postanowiłem pójść o krok naprzód i zacząłem zbierać statystyki z tych gier. Zbierałem 3 rodzaje statystyk:
- Win ratio kart żetonów.
- Win ratio kombinacji par kart żetonów w zestawach.
- Win ratio kart żetonów przeciwko innym określonym kartom żetonów.

Właściwie to nie do końca było to prawdziwe "win ratio". Zamiast tego wszystkie podstawowe wartości ustawiłem na 0.5, a po każdej grze mnożyłem odpowiednie wartości przez 0.999, oraz dodawałem 0.001 w przypadku wygranych. Od zwykłego win ratio różni się tym, że największy wpływ na statystyki mają najnowsze gry, dzięki czemu można szybko obserwować zmiany wynikłe ze zmian w balansie/AI, ale jednocześnie statystyki nie wahają się zbytnio wskutek losowości. Alternatywnym rozwiązaniem byłoby zapamiętywanie historii tylko 1000 ostatnich gier, przy początkowym wypełnieniu ich naprzemiennymi wygranymi i przegranymi, ale takie rozwiązanie nie byłoby praktyczne.

Takie statystyki są bardzo pomocne. Jeśli jakaś karta ma zbyt duże win ratio, to znaczy że albo AI nie potrafi sobie z nią radzić, albo ta karta jest zbyt silna. Jeśli jakaś karta ma zbyt niskie win ratio, to albo AI nie potrafi z niej poprawnie korzystać, albo jest zbyt słaba. Dzięki tym statystykom szybko zorientowałem się po ostatniej zmianie w działaniu umiejętności dającej bonusową turę właścicielowi AI nie posługiwało się nią prawidłowo, więc szybko mogłem naprawić ten problem.

Mimo iż statystyki obejmowały wiele gier, to nie uznawałem je za zbyt wartościowe. Powód jest prosty: karty żetonów posiadają różną skuteczność w zależności od tego jakie ma się pozostałe karty w zestawie. Przykładowo jeśli posiadamy żeton który wzmacnia przyrost punktacji sąsiednich żetonów, to będzie on znacznie lepiej sobie radził jeśli będziemy posiadali umiejętności które generują wiele żetonów.

Poprawiony generator zestawów kart żetonów

Skoro już zebrałem statystyki dotyczące synergii żetonów, postanowiłem je wykorzystać. Dotychczas zestawy były generowane w taki sposób, aby w pierwszej kolumnie były losowe żetony którymi opłaca się rozpocząć grę, a prócz tego jedyną zasadą był brak powtórek kart - wszystkie miały tę samą wagę. Postanowiłem to zmienić i przerobiłem algorytm tak, aby wagi wszystkich dostępnych kart były wymnażane przez win ratio każdej dodawanej do zestawu talii. Innymi słowi jeśli jakaś karta nie zgrywa się z tymi które już wylosowaliśmy, to mieliśmy mniejszą szansę na jej otrzymanie.

Ku moim oczekiwaniom po wprowadzeniu tej zmiany win ratio poszczególnych elementów zmieniło się. Jeśli jakiś element wymagał synergii, to jego win ratio wzrosło, bo zwyczajnie rzadziej zdażały się sytuacje w których ta synergia nie występowała. Co więcej zmiana ta spowodowała nieustanne zmiany w win ratio wszystkich kart - jeśli jakaś karta okazywała się słaba, zaczęła występować rzadziej, a wtedy karty przeciwko którym była skuteczna zaczęły występować częściej, ponieważ zginęło ich naturalne zagrożenie. Elementy papier-kamień-nożyce w tej grze są dosyć mocne, więc takie zmiany mogą zachodzić niemal bez końca.

Balans na podstawie statystyk

Karty żetonów które mocno wychodziły na plus postanowiłem osłabić, a karty które radziły sobie okropnie postanowiłem wzmocnić. Zmieniałem tylko te karty, które odstawały najmocniej, a następnie analizowałem zmiany. Zmienione karty albo pozostawały słabe/silne (choć w nieco mniejszym stopniu), albo stawały się przeciętne, co pozwala mi sądzić że zmiany były w miarę rozsądne.

Wraz z wprowadzaniem zmian można było zauważyć, że zmiana najbardziej skrajnych kart sprawiała tylko, że mniej skrajne karty zajmowały ich miejsce, co jest dysyć przewidywalne, bo jeśli jakiś element przestał być słabszy od innego elementu, to prawdopodobnie będzie teraz z nim wygrywać.

Inną przewidywalną zależnością było dążenie statystyk do małej liczby kart z pozytywnym win ratio, przy jednoczesną nieznaczną pozytywnością. Win ratio żadnej karty nie przekroczyło 53%, a większość pozytywnych kart miała w przybliżeniu 51% win ratio, z kolei siła słabych kart była bardzo różnorodna i potrafiła sięgać nawet 37%. Powód tego był prosty: najlepsze karty występowały w zestawach częściej, a więc "wygrani grali z wygranymi". Nawet gdyby któraś karta początkowo miała 90% win ratio, to po rozegraniu dużej liczby gier z przeciwnikiem na tym samym poziomie win ratio zbliżyłoby się do 50%. Z kolei słabsze karty rzadko miały okazję grać naprzeciw słabszym kartom, a więc ich win ratio spadało w dół.

Przy całej tej zabawie najbardziej rzuciło mi się w oczy, że karta z żetonem o wartości 9 (dotychczas 8) dająca bonusową turę przeciwnikowi na przemian miała neutralne i negatywne win ratio. Gdy jej win ratio stawało się neutralne, win ratio kart które mogły wypchnąć ten żeton poza planszę wzrastało. Gdy wzrastało win ratio kart przemieszczających, to win ratio karty z bonusową turą dla przeciwnika malało, a gdy zmalało, to win ratio kart wypychających znowu spadało. Spośród wszystkich kart ta jest chyba najbardziej ryzykowna.

Oto grafika przedstawiająca zebrane dane: po lewej jest win ratio poszczególnych kart, po środku jest win ratio ich kombinacji, a po prawej jest win ratio ich potyczek:


Czysty żółty kolor oznacza 50% win ratio, czysty zielony 60% win ratio, czysty czerwony oznacza 40% win ratio, a czysty fiolet oznacza 30% win ratio. Kolory pośrednie to oczywiście wartości pośrednie.

Póki co zagwozdkę dla mnie stanowią karty które mają inną skuteczność mimo iż różnią się tylko obszarem umiejętności, nawet jeśli obszar umiejętności obejmuje tyle samo pól. O ile w przypadku umiejętności przemieszczających łatwo zrozumieć dlaczego tak się dzieje (umiejętności przemieszczające po skosie częściej natrafiają na sytuacje w której mogą coś wypchnąć poza planszę), to w przypadku innych umiejętności nie jest to takie jasne. Różnica ta jest najbardziej widoczna w przypadku umiejętności przejmującej, która w działaniu skośnym ma lepsze wyniki. Przypuszczam że jest to związane z żetonem który co turę właściciela rani wrogów w linii pionowej/poziomej - wtedy przejęcie żetonów po skosie jest dla nas bardziej opłacalne, ponieważ można przejąć więcej żetonów przy jednoczesnym nie narażaniu ich na obrażenia.

No cóż, pozostaje mi posiedzieć jeszcze nad sztuczną inteligencją. Obecnie unika ona stawiania wartościowych żetonów w obszarze obrażeń wieżyczek, więc to może być przyczyną niektórych zaburzeń. Niedługo dojdzie jeszcze nieco nowych kart, które także mogą zamieszać w balansie.

środa, 25 maja 2016

Aktualizacja danych

W trakcie tworzenie gry może się zdarzyć, że chcemy dodać do programu funkcjonalność której przedtem nie przewidzieliśmy. Zazwyczaj nie sprawia to kłopotów, ale schody zaczynają się wtedy, gdy ta funkcjonalność wymaga innego sposobu zapisu danych o użytkownikach. Z taką sytuacją spotkałem się gdy chciałem, aby gracz miał możliwość nazywania swoich zestawów żetonów. Dotychczas dane każdego zestawu żetonów zawierały tylko 5 linijek danych - numer zestawu oraz 4 stosy kart żetonów (gdzie każdy numer karty był oddzielony spacją) i miały taki format:

set 0
0 1
2 3
4 5 8
6 7 9

Teraz natomiast zależy mi na formacie danych, który zawierałby także informacje o nazwie zestawu, którą nadał jemu graczu:

set 0
Deck 1
0 1
2 3
4 5 8
6 7 9

Zapis danych w nowym formacie nie powinien być problemem, podobnie jak dostosowanie kodu w taki sposób, aby poprawnie ten zapis odczytywał. Problem polega jednak na tym, że po dostosowaniu kodu do nowego formatu danych nie będzie on już w stanie odczytywać dotychczasowych danych użytkowników. Trzeba więc dodać do programu funkcjonalność, która zamieni dane w starym formacie na dane w nowym formacie. Tutaj pojawia się kolejny problem: jeśli aktualizacja danych będzie polegała na dodaniu do pliku paru linijek tekstu, to po kolejnym uruchomieniu skryptu nowe linijki danych powielą się, tworząc coś takiego:

set 0
Deck 1
Deck 1
0 1
2 3
4 5 8
6 7 9

Trzeba więc dodać do skryptu coś, co pozwoli rozpoznać obecną wersję danych.

Porządkowanie danych

Aby gra była w stanie rozpoznać wersję danych, należy dodać na serwer plik przechowujący informację o wersji danych - jeśli według nowej wersji gry dane użytkowników będą przestarzałe, to wszystkie dane zostaną poprawione, a numer wersji uaktualniony. Pojawia się tutaj drobny problem: jeśli informacja o wersji będzie znajdowała się w tym samym folderze co dane użytkowników, to program mógłby mylić ze sobą te pliki. Dlatego więc utwórzmy nowy folder i dodajmy do niego plik tekstowy:

 if (!Directory.Exists (@"C:/TokenBattle/Version")) {
  Directory.CreateDirectory (@"C:/TokenBattle/Version");
  File.WriteAllText (@"C:/TokenBattle/Version/version.txt", "v0.01");
 }

Od teraz przy uruchomieniu gry na serwerze będziemy mogli sprawdzić wersję danych i na jej podstawie określić, czy potrzebne są aktualizacje.

Dla porządku wypadałoby też stworzyć specjalny folder dla danych o użytkownikach:

 Directory.CreateDirectory (@"C:/TokenBattle/Users");

Do tego folderu przeniesiemy wszystkie dane użytkowników. Aby łatwo operować na plikach, potrzebne są nam ich nazwy, a można je pozyskać funkcją Directory.GetFiles (), która zwraca tablicę stringów zawierających ścieżki plików zawartych w folderze którego ścieżkę podamy w argumencie, np.:

 string [] FileNames = Directory.GetFiles (@"C:/TokenBattle");

Jeśli plik użytkownika nazywałby się user1.txt, to ścieżka tego pliku miałaby format C:/TokenBattle\user1.txt. Zależy nam, aby po modyfikacji ścieżka tego pliku brzmiała: C:/TokenBattle/Users\user1.txt, a skoro początek ścieżki zawsze będzie zaczynał się tak samo, to potrzebujemy tylko końcówki tej ścieżki, będącej nazwą pliku. Musimy więc wyciąć ze stringa wszystko co znajduje się przed znakiem \ i dla każdego pliku musimy to wykonać osobno, czyli musimy to wykonać dla wszystkich stringów w tablicy stringów:

 foreach (string fileName in FileNames) {
  string fName = fileName.Remove (0, fileName.IndexOf ("\\") + 1);
 }

Funkcja Remove przyjmuje 2 parametry: pierwszym parametrem jest index znaku od którego chcemy kasować tekst (włącznie), a drugim parametrem jest liczba znaków które chcemy skasować. Pierwszym argumentem będzie więc 0, a drugim argumentem powinna być liczba znaków do znaku \ (włącznie), czyli 21. Teoretycznie moglibyśmy tu podstawić stałą wartość, jednak jest to niewygodne, chociażby dlatego, że możemy się pomylić. Bezpieczniej jest więc użyć funkcji IndexOf, która zwróci indeks wystąpienia wybranego przez nas znaku, którym jest znak \. Znak ten jest na swój sposób wyjątkowy, ponieważ w programowaniu oznacza on rozpoczęcie różnych specjalnych operacji (np. \r\n powoduje utworzenie nowej linii), dlatego ustalono, że jeśli ktoś chce wstawić ten znak jako znak tekstowy, musi go napisać dwukrotnie.

Skoro mamy już wyodrębnioną nazwę pliku, możemy przenieść plik z jednego folderu do drugiego korzystając z funkcji Move:

 File.Move (@"C:/TokenBattle/" + fName, @"C:/TokenBattle/Users/" + fName);

Pierwszym argumentem tej funkcji jest ścieżka pliku który chcemy przenieść, a drugim argumentem jest ścieżka pliku którą chcemy uzyskać.

Modyfikacja danych o użytkownikach

Teraz pozostaje nam tylko zmodyfikować pliki. Aby to zrobić, musimy najpierw wczytać ich zawartość:

 List <string> Lines = new List <string> (File.ReadAllLines (@"C:/TokenBattle/Users/" + fName));

Funkcja File.ReadAllLines zwraca tablicę stringów, które są zawartością pliku o ścieżce podanej w argumencie. Jednak tablice mają pewną wadę: nie można na nich wykonywać wielu złożonych informacji, takich jak np. dodanie stringa w środku tablicy - można go dodać tylko na jej końcu. Skorzystamy więc z list, które oferują większe możliwości niż zwykłe tablice. Użytkownik może mieć zapisane na koncie wiele zestawów żetonów, więc program powinien odnaleźć wszystkie z nich.

 int HandsetNumber = 0;
 while (Lines.IndexOf ("set " + HandsetNumber.ToString ()) != -1) {
  Lines.Insert (Lines.IndexOf ("set " + HandsetNumber.ToString ()) + 1, "Deck " + (HandsetNumber + 1).ToString ());
  HandsetNumber++;
}

Na początku tworzymy tu zmienną, która będzie zapamiętywała numer aktualnie przeglądanego zestawu żetonów. Jeśli w danym pliku znajduje się zestaw z tym numerem, to znaczy że musimy go zmodyfikować, dodając nazwę tego zestawu, a następnie musimy zwiększyć zmienną z numerem i wszystko zapętlamy do momentu, aż w pliku skończą się zestawy. Funkcja IndexOf zwraca na wyjście -1, jeśli w liście nie znajduje się element równy temu podanemu w argumencie, co w naszym przypadku spowoduje przerwanie pętli. Funkcja Insert przyjmuje 2 argumenty - pierwszym argumentem jest numer indeksu na który chcemy dodać nowy element listy (następne elementy listy zostaną odpowiednio przesunięte), a drugim argumentem jest wartość tego elementu listy, czyli w naszym przypadku string. W programowaniu panuje zwyczaj numerowania rzeczy od 0, ale nazwa zestawów żetonów będzie widoczna w grze dla użytkowników, czyli nazwy zestawów wypadałoby numerować od 1. Prócz tego nazwa zestawu w przeciwieństwie do jego poprzedniego numeru będzie mogła być modyfikowana przez użytkowników.

Następnie pozostaje tylko zapisać zmiany:

 File.WriteAllLines (@"C:/TokenBattle/Users/" + fName, Lines.ToArray());

Odpowiednie złożenie tego wszystkiego w całość i dodanie do kodu sprawi, że nasz program będzie mógł zmodyfikować dane, jeśli okażą się nieaktualne.

sobota, 21 maja 2016

Sztuczna inteligencja

Póki co gra posiada LAN dzięki któremu można grać z drugim graczem, ale pojawia się problem, gdy drugiego gracza nie ma. Trochę głupio byłoby grać samemu ze sobą. Warto jest więc dodać do gry sztuczną inteligencję, tym bardziej że większość gier takową posiada. Samo dodanie sztucznej inteligencji nie powinno być trudne, skoro gra posiada już mechanizmy umożliwiające grę obu graczom.

Na jakiej zasadzie będzie działała sztuczna inteligencja (AI)?

AI będzie sprawdzało, jaki wpływ na przyrost punktacji obu graczy będzie miało postawienie żetonu na wybranym polu planszy, oraz będzie sprawdzało kombinacje każdego dostępnego żetonu z każdym dostępnym polem. Jeśli mamy więc planszę o wymiarach 6x6, oraz 4 żetony na ręce, to AI sprawdzi 144 różnych kombinacji ruchów i na podstawie wyników testu ustali jaki żeton użyć na jakim polu. Mimo tak obszernych danych AI nadal będzie posiadało wiele wad, oto kilka sytuacji, których AI nie będzie sprawdzało:
- jaki wpływ na nasze kolejne tury będzie miał wystawiany żeton,
- czy wykonanie ruchu spowoduje utratę szansy na tymczasową premię (którą zamiast nas otrzymałby przeciwnik wykonując inny ruch),
- czy wykonanie innego ruchu nie doprowadziłoby do natychmiastowej wygranej (jeśli do wygranej brakuje nam niewiele punktów),
- czy wykonanie innego ruchu nie uniemożliwiłoby przeciwnikowi natychmiastowej wygranej (jeśli do wygranej brakuje jemu niewielu punktów),
- jakie ruchy u(nie)możliwi użycie żetonu (przykładowo usunięcie jakiegoś żetonu z planszy może pozwolić przeciwnikowi wykonać bardziej korzystny ruch).

Pełna lista takich sytuacji jest oczywiście większa i dodatkowo będzie rosła wraz z każdym nowym rodzajem żetonu lub umiejętności. Wady AI sprawiają, że żywy gracz jest w stanie wygrać z komputerem, a dostrzegając błędy AI sam będzie mógł szybciej nauczyć się unikać. Oczywiście AI można rozbudowywać w taki sposób, aby popełniało coraz mniej błędów i dzięki temu moglibyśmy stworzyć nawet kilka poziomów AI, dzięki czemu gracz będzie mógł wybrać godnego sobie przeciwnika bez względu na swój poziom.

Warto jednak zauważyć, że im więcej sytuacji będzie sprawdzało AI, tym więcej operacji będzie musiał wykonać serwer. Jeśli założymy, że sprawdzenie ruchu wymaga wykonanie n operacji, to sprawdzenie wszystkich możliwych turów w jednej turze wymagałoby zaledwie 144 * n operacji, co zajmie sprzętowi malutki ułamek sekundy. Problem pojawia się wtedy, gdy chcemy uwzględnić znacznie większą liczbę tur i np. wybrać ruch który byłby najlepszy przewidując jego wpływ na tą turę oraz 3 kolejne, wtedy liczba operacji wzrosłaby do 144^4 * n, co prawdopodobnie zajęłoby przeciętnemu sprzętowi co najmniej kilkadziesiąt minut. Oczywiście są pewne metody, które pozwalają tą liczbę zminimalizować, ale póki co się tym nie zajmujemy - na razie chcemy mieć jakiegokolwiek przeciwnika, czyli jakiekolwiek AI.

Przy programowaniu AI zachodzi jeszcze jeden drobny problem - zawsze działa zgodnie z algorytmem, co prowadzi do dużej przewidywalności. Gdy gramy z jakimś graczem kilka razy pod rząd, to zwykle staramy się unikać błędów które ostatnio wykorzystał przeciwnik. Nasz styl gry zmienia się też w zależności od jego taktyki. Sprawia to, że jeśli gramy z żywym nawet kilka razy pod rząd, to za każdym razem rozgrywka jest na swój sposób unikatowa. Jeśli AI nie będzie posiadało tego typu unikalności, to stanie się zbyt przewidywalne i nudne. Aby tego uniknąć, można zastosować 2 rozwiązania:
- Zaprogramowanie kilku zupełnie innych stylów gry które będą zmieniały się z każdym kolejnym meczem lub w trakcie meczu.
- Zaprogramowanie AI tak, aby jego zachowanie było częściowo losowe.
Postawię na drugą opcję, choć może w przyszłości nawet połączę obie.

Skrypt

W skrypcie AI pierwszą wykonaną rzeczą będzie uporządkowanie pól na planszy i kart w losowej kolejności, co sprawi, że AI będzie zachowywało się nieschematycznie. Jeśli jakaś opcja będzie najbardziej opłacalna, to oczywiście zostanie wybrana, ale jeśli będzie remisowała opłacalnością, to będzie wybierana ta z największym losowym numerem.

Najpierw inicjalizuję zmienne:

 int MaxC = GameData.HandSize;
 int SizeX = GameData.MapSizeX;
 int SizeY = GameData.MapSizeY;
 int SizePow = SizeX * SizeY;
 int [] RNG = new int [SizePow];
 int [,] RNGOrder = new int [SizeX, SizeY];
 int [] RNGCardOrder = new int [MaxC];

Zmienne te będą przechowywać nieco skracać nasze funkcje, oraz będą zawierały informacje o tym w jakiej kolejności mają być przeglądane pola/karty. Przypisanie tablicy losowych wartości z jakiegoś zakresu bez powtórzeń jest proste, wystarczy utworzyć tablice z kolejnymi liczbami pierwszymi:

 temp = new int [SizePow + 1];
 for (int x = 0; x < SizePow; x++) {
  temp [x] = x;

 }

A następnie brać losowe wartości z tej tablicy przy jednoczesnym usuwaniu wartości w niej wylosowanych:

 for (int x = 0; x < SizePow; x++) {
  rng = UnityEngine.Random.Range (0, SizePow - x);
  RNG [x] = temp [rng];
  for (int y = rng; y < SizePow; y++) {
   temp [y] = temp [y + 1];
  }

 }

Warto tu jednak zauważyć, że tablica z rozmiarem planszy jest przedstawiona jako tablica jednowymiarowa, a do zapisu planszy wszędzie używałem tablicy dwuwymiarowej. Dlatego uzyskane wartości wystarczy przepisać do dwuwymiarowej tablicy.

 for (int x = 0; x < SizeX; x++) {
  for (int y = 0; y < SizeY; y++) {
   RNGOrder [x, y] = RNG [x * SizeY + y];
  }

 }

Teraz deklarujemy zestaw zmiennych do przechowywania informacji o dotychczas najlepszym ruchu.

 int BestValue = -100;
 int BestC = 0;
 int BestX = 0;

 int BestY = 0;

I przechodzimy do analizy każdej kombinacji pól:

for (int c = 0; c < MaxC; c++) { // Sprawdzenie każdej karty
 int Card = RNGCardOrder [c]; // Aby skrócić zapis
 for (int x = 0; x < SizeX; x++) { // Sprawdzenie każdej kolumny planszy
  for (int y = 0; y < SizeY; y++) { // Sprawdzenie każdego wiersza planszy
   if (!SGetTokenExist (x, y)) { // Sprawdzenie, czy pole jest wolne
    int TempValue = TokenValue (Card, PNumber); // Wartość ruchu
    // Uwzględnienie siły umiejętności
    for (int z = 0; z < AbilitySize (Card, PNumber); z++) { // Sprawdzenie każdego pola na które wpływa umiejętność
     int cx = x + AbilityX (Card, z, PNumber);
     int cy = y + AbilityY (Card, z, PNumber);
     if (CheckWithMap (cx, cy)) { // Sprawdzenie czy nie wyszliśmy poza planszę
      switch (AbilityType (Card, PNumber)) { // Wybranie metody sprawdzania efektu umiejętności na podstawie jej rodzaju
       case 1: // Umiejętność typu 1
        if (SGetTokenExist (cx,cy)) { // Sprawdzenie czy na polu objętym umiejętnością jest jakiś żeton
         if (SGetTokenPlayer (cx, cy) == PNumber) { // Sprawdzenie czy żeton należy do nas
          TempValue -= 1;
         } else {
          TempValue += 1;
         }
        }
       break;
     }
   }
   if (TempValue > BestValue || (TempValue == BestValue && RNGOrder [x, y] > RNGOrder [BestX, BestY])) { // Sprawdzenie, czy jest to opcja z najwyższym numerem spośród najlepszych opcji
     BestValue = TempValue;
     BestC = Card;
     BestX = x;
     BestY = y;
     }
    }
   }
  }

 }

W tej grze będzie wiele rodzai umiejętności, a więc każdy żeton będzie sprawdzany inaczej. Fragment kodu można podzielić na wiele części i do każdej części dodać osobny warunek if, który będzie sprawdzał, czy ten fragment kodu powinien być realizowany w danej chwili. Jednak pisanie wielu niemal identycznych ifów może być z czasem irytujące, więc w takich sytuacjach warto jest korzystać z przełącznika switch, który realizuje inny fragment kodu, w zależności od wprowadzonego parametru.

Na podstawie indeksów które otrzymaliśmy w wyniku testu używamy określonej karty żetonu na określonym polu, do czego posłużyłaby ta sama funkcja, z której normalnie korzysta gracz.

Na co trzeba jeszcze zwrócić uwagę:
- Gra operuje na systemie kont, a więc powinien on być dostosowany w taki sposób, aby mogło z niego korzystać także AI i było ono możliwe do rozpoznania,
- Gra powinna pomijać tworzenie elementów graficznych dla AI (plansza, żetony, efekty itd.),- Podłączenie się AI do gry nie powinno zapisywać na kliencie informacji o jego koncie (co mogłoby spowodować np. uznanie gracza za AI),
- AI powinno wykonać ruch po każdym ruchu gracza - tutaj warto dodać jakiś warunek który sprawi, że AI nie będzie wykonywało ruchu po samym sobie.
- Ruch AI powinien być opóźniony, aby sytuacja na planszy była dla gracza bardziej czytelna.

W przyszłości AI będzie rozbudowywane i będą dodawane nowe poziomy trudności.

sobota, 14 maja 2016

Komunikaty w grze

W grze może zaistnieć wiele sytuacji, w których powinien wyświetlić się jakiś komunikat. Przykładowo jeśli gracz wpisze niepoprawne hasło, to gra powinna go o tym powiadomić.

Pierwszym krokiem będzie utworzenie prefabu, który zawierałby komunikat. Będzie się on składał z obiektów:
- empty GameObject (pełniący rolę parenta następnych obiektów),
- UI/Image (pełniący rolę tła dla komunikatu),
- UI/Text (pełniący rolę tekstu komunikatu),
- UI/Button (pełniący rolę przycisku zamykającego komunikat).


Wszystkim obiektom nadajemy odpowiednie właściwości (skalę, pozycję itd,), a następnie wrzucamy w prefab i przechodzimy do tworzenia skryptów.

Komunikaty warto jest zrobić w taki sposób, aby mogły być wyświetlane z poziomu każdego skryptu i każdej sceny, a więc funkcję tworzącą komunikat warto jest zapisać w formie funkcji statycznej:

 static public void ShowMessage (string s) {
  GameObject Clone = Instantiate (Resources.Load ("PreMessage")) as GameObject;
  Clone.transform.parent = GameObject.Find ("Canvas").transform;
  GameObject MessageText = Clone.transform.Find ("MessageText").gameObject;
  MessageText.GetComponent <Text> ().text = s;

 }

Funkcja ta przyjmuje za argument string (ciąg znaków), który będzie treścią komunikatu. Pierwszą rzeczą robioną przez skrypt jest utworzenie instancji assetu "PreMessage" znajdującego się w folderze Resources, który zostaje przypisany do zmiennej Clone. Kolejną rzeczą jest znalezienie na scenie obiektu o nazwie "Canvas", a następnie ustalenie go jako parent dla naszego komunikatu. Canvas jest obiektem który jest wymagany do korzystania z obiektów UI (interfejsu użytkownika). Dotychczas Canvasa znajdywała się w prefabie ekranu logowania/rejestracji, co było wygodne, gdy był to jedyny UI w grze. Teraz moim zdaniem lepiej byłoby mieć na każdej scenie tylko jedną Canvasę i do niej przypinać wszystkie obiekty UI. Kolejną rzeczą wykonywaną przez skrypt jest znalezienie obiektu o nazwie "MessageText" (który w naszym przypadku jest obiektem tekstowym komunikatu), ale obiekt ten nie jest szukany na scenie, lecz w transformie naszej instancji, czyli jest szukany pośród childów naszego obiektu. Ostatnim elementem skryptu jest przypisanie wartości argumentu funkcji do komponentu tekstowego znalezionego obiektu. 

Potrzebny jest jeszcze jakiś skrypt, który powodowałby usunięcie komunikatu po naciśnięciu przycisku. Tworzymy więc nowy skrypt i dodajemy do niego publiczną funkcję, aby móc się do niej odwołać z komponentu przycisku. Skrypt można przypisać albo do przycisku, albo to jego ojca, a w zależności od wyboru inaczej trzeba będzie napisać skrypt. Jeśli będzie on przypisany do ojca przycisku, to wtedy skrypt będzie musiał zniszczyć obiekt, do którego jest przypisany:

 public void SelfDestroy () {
  Destroy (gameObject);
 }

Można też postąpić inaczej, czyli przypisać skrypt do przycisku, a wtedy skrypt też będzie musiał zniszczyć ojca przycisku, ale wtedy nie będzie to obiekt do którego jest przypisany skrypt, W takim przypadku funkcja będzie wyglądała tak:

 public void SelfDestroy () {
  Destroy (gameObject.transform.parent.gameObject);
 }

I na koniec należy dodać do przycisku odwołanie do tej funkcji, aby aktywowała się po kliknięciu na przycisk:

wtorek, 3 maja 2016

Wizualizacja umiejętności

Aby rozgrywka była dla gracza czytelna, gra musi wizualizować wszystkie istotne wydarzenia, które dzieją się w trakcie gry. Dzięki efektom graficznym gracz jest w stanie łatwo określić jakie czynności wykonują przeciwnicy. Takie efekty nie muszą wyglądać widowiskowo, szczególnie gdy miałoby to zmniejszyć czytelność rozgrywki - wszystko to co dzieje się na ekranie powinno być dla gracza jednoznaczne.

Przygotowywanie obiektu


Moim celem jest stworzenie efektu, który reprezentowałby prostą umiejętność, która redukuje wartość żetonów na określonych polach. Będzie to nieskomplikowana sześcienna poświata, której intensywność rośnie na początku trwania efektu, a po chwili maleje, doprowadzając do końca efektu.

Najpierw tworzymy na scenie pusty GameObject, do którego doczepimy 4 quady (dzięki czemu pozycję, rotację i skalę tych quadów będzie można łatwo modyfikować przekształcając pusty GameObject). Quady te będą bocznymi ścianami efektu i należy je ustawić tak, aby reprezentowały boczne ściany sześciany. Następnie importujemy do projektu teksturę w formacie png, która przedstawia przejście białego koloru w przeźroczystość. Zmiana koloru materiału z poziomu skryptu sprawia, że wartości rgb pliku są mnożone przez nową wartość, czyli gdybyśmy kolor naturalnie czerwonej tekstury (r = 1, g = 0, b = 0) ustawili na niebieski (r = 0, g = 0, b = 1), to ostatecznie otrzymalibyśmy czarną teksturę (r = 0, g = 0, b = 0). Dlatego moim zdaniem w przypadku jednolitych tekstur warto jest stosować kolor biały, który potem można przemienić w dowolny inny kolor bez konieczności dodawania kolejnych tekstur. Wyjątkiem od tej reguły są m.in. sytuacje, w których wybrany typ shaderu nie pozwala na modyfikację koloru materiału. Zaimportowaną teksturę przypisujemy do quadów i ustawiamy jej kolor na czerwony.

Kolejną czynnością jest, zmienienie shadera materiału naszego efektu na shader "Sprites/Default".

Jeśli się przyjrzymy, to zauważymy, że na górnej krawędzi naszego efektu znajduje się wąska linia, mimo iż tamta krawędź powinna być przezroczysta:


Niby linia jest stosunkowo trudna do zauważenia, ale wypada się jej pozbyć. Jej istnienie jest spowodowana tym, że tekstura jest w pewnym sensie zapętlona i rozmyta, przez co widać fragment przeciwległej krawędzi tekstury. Aby tego uniknąć należy wybrać użytą teksturę i ustawić w inspektorze wartość wrap mode z wartości repeat na clamp:


Następnie z quadów usuwamy collidery, aby nie przeszkadzały nam w grze i wszystko to wrzucamy w prefab, który umieszczamy w folderze Resources.

Skrypt


Chcemy aby nasz efekt pojawiał się w momencie aktywacji umiejętności żetonów, oraz wysuwał się spod pól znajdujących się na planszy, które zostały objęte działaniem tej umiejętności. Aby to ułatwić, podczas tworzenia instancji tego efektu graficznego oznaczmy pole jako parent dla tego efektu., Dzięki temu przykładowo po ustaleniu lokalnej pozycji na (0, 0, 0) nasz prefab będzie się znajdował dokładnie w miejscu pola. Warto się upewnić, że rotacja poszczególnych quadów w prefabie będzie odpowiadała rotacji którą chcemy uzyskać, dzięki czemu nie będziemy już musieli ustawiać jej za pomocą skryptu.

Tworzymy nowy skrypt i deklarujemy w nim 3 zmienne:

 public bool AutoDestroy;
 float Timer;

 float TimeScale = 0.5f;

Pierwsza zmienna będzie decydowała o tym, czy obiekt ma być automatycznie niszczony po jakimś czasie. Nieautomatyczne niszczenie obiektu mogłoby być przydatne gdybyśmy chcieli aby efekt pojawiał się gdy gracz najedzie myszką na pole w celu sprawdzenia na jakie pola wpłynie umiejętność - wtedy efekt powinien znikać dopiero po przesunięciu kursora poza pole. Z kolei automatyczne niszczenie efektu byłoby pożyteczne po normalnym użyciu umiejętności. Druga zmienna będzie przechowywać informację o tym, jak długo obiekt istnieje, a trzecia zmienna jak długo ma trwać wynurzanie się się efektu.

Kolejną rzeczą na której nam zależy jest dodanie funkcji Start, która posłuży nam do ustawienia odpowiedniej pozycji obiektu:

 void Start () {
  transform.localPosition = new Vector3 (0, 0, 1);

 }

Kamera w grze jest ustawiona w taki sposób, że zwiększenie wartości współrzędnej Z powoduje oddalenie się obiektu od kamery, czyli efekt będzie się znajdował pod planszą w momencie przypisania skryptu.

I następnie dodajemy główny fragment kodu, który ma być wykonywany co klatkę gry

 void Update () {
  Timer += Time.deltaTime;
  if (Timer < TimeScale) {
   transform.localPosition = new Vector3 (0, 0, 2 * (TimeScale - Timer));
  } else if (AutoDestroy) {
   if (Timer < 2 * TimeScale) {
    transform.localPosition = new Vector3 (0, 0, 2 * (Timer - TimeScale));
   } else {
    Destroy (gameObject);
   }
  } else {
   transform.localPosition = new Vector3 (0, 0, 0);
   Destroy (GetComponent<EvadingScript> ());
  }

 }

Timer co klatkę zwiększa się o czas odstępu między tą a poprzednią klatką gry, czyli mierzy czas istnienia obiektu. Jeśli timer jest mniejszy niż czas w trakcie którego obiekt ma się przybliżać do kamery, to jego pozycja na osi Z maleje. Potem skrypt sprawdza, czy obiekt ma być automatycznie zniszczony - jeśli tak, to zaczyna się chować, w przeciwnym razie jego pozycji zostanie przypisany wektor (0, 0, 0). Przypisanie tej pozycji jest ważne, ponieważ odstępy pomiędzy klatkami mogą mieć różny odstęp i gdyby liczba klatek na sekundę była bardzo niska, to mogłoby się zdarzyć, że pozycja na osi Z byłaby równa np. 0.57, przez co efekt byłby ledwo widoczna, mimo iż w tym momencie powinien być widoczny całkowicie. Dodatkowo jeśli czas zostanie przekroczony, to skrypt kasuje sam siebie, ponieważ nie chcemy, aby program niepotrzebnie co klatkę wykonywał czynności umieszczona przez nas w tym skrypcie. Gdy nasza zmienna AutoDestroy jest ustalona jako true, to program zamiast tego co klatkę zwiększa wartość wektora pozycji na osi Z, co powoduje chowanie się obiektu. Po upłynięciu odpowiedniej ilości czasu obiekt do którego przypisany jest skrypt kasuje sam siebie wraz ze swoimi komponentami.

W podobny sposób możemy utworzyć poziomy odpowiednik tej umiejętności, który nie będzie się wysuwał spod ziemi, lecz początkowo będzie przeźroczysty i będzie stawał się bardziej czerwony w trakcie wysuwania się efektu, co w połączeniu z drugim efektem sprawi wrażenie, że efekt nie składa się z kilku ścian, lecz jest bryłą: