wtorek, 31 maja 2016

Stan obecny gry i plany na przyszłość

Dzisiaj osiągnąłem już stan gry który uważam za grywalny. Mimo iż grę mogę już prezentować, to pozostawia wiele do życzenia i nadal potrzebuje ona dużego nakładu pracy. Można ją pobrać pod tym adresem:
https://drive.google.com/open?id=0B_YKEPym2wKDVGJKZk9Vb0pPVk0

Kod na gicie:
https://github.com/AwareOwl/TokenBattle

Obecnie gra działa tylko na LANie i przechowuje dane na komputerze który pełni rolę hosta. W grze dostępne są następujące tryby gry:
- Easy AI
- Normal AI
- LAN (umożliwia grę pomiędzy każdą uruchomioną grą w sieci LAN),
- Hotseat (gracze wykonują swoją turę naprzemiennie),
- Sandbox (gra bez przeciwnika).

Gracze mają dostęp do 20 żetonów, z których mogą składać zestawy żetonów. Na chwilę obecną istnieją 3 rodzaje umiejętności żetonów:
- Redukcja wartości - redukuje wartość żetonów objętych działaniem umiejętności,
- Odepchnięcie - żeton odpycha od siebie żetony objęte działaniem umiejętności (może spowodować wypchnięcie żetonu poza planszę).
- Przerzucający - przerzuca żetony objęte działaniem umiejętności na pole symetryczne względem żetonu (może spowodować wypchnięcie żetonu poza planszę).

W planach między innymi:
- Dodanie nowych poziomów AI.
- Możliwość zapisu wielu zestawów żetonów.
- Możliwość wybrania zestawu żetonów dla AI oraz przeciwnika w trybie hotseat.
- Dodanie nowych rodzai żetonów.
- Dodanie nowych rodzai umiejętności.
- Wprowadzenie samouczka.
- Dodanie nowej szaty graficznej.
- Dodanie opcji zmiany tempa rozgrywki.
- Dodanie dźwięków.
Oraz wiele innych zmian.

Prosiłbym o zgłaszanie błędów i sugestii.

niedziela, 29 maja 2016

Równowaga w grze.

W Token Battle elementami gry z których korzysta gracz są żetony. Każdy żeton ma swoje właściwości i parametry, które sprawiają, że jest w pewnym sensie unikalny na tle innych żetonów. Równowaga w grze panuje, gdy każdy z jej elementów ma w sobie coś, co w jakiejś sytuacji może uczynić go lepszym od innych elementów gry i vice versa. Jeśli gracz zastanawiając się nad siłą jakiegoś elementu stwierdzi, że jest on lepszy od innych elementów w każdej sytuacji, to wtedy ten element jest zbyt silny i zaburza równowagę gry.

Dlaczego warto utrzymywać równowagę w grze?

Jeśli jakiś element gry jest zbyt silny, to stopniowo zaczynają korzystać z niego wszyscy gracze. Doprowadza to do powtarzalności w grze - ten sam element jest widziany w każdej grze, a więc każdy się go spodziewa. Jeśli jakaś sytuacja powtarza się w każdej grze, to gracze szybko znajdują najlepszy schemat reakcji na tą sytuację i każda kolejna gra staje się jedynie odtwarzaniem poprzedniej gry. Tego typu schematyczność można omijać na wiele sposobów, np. dodając do gry element losowości, albo narzucać limity na te same zagrania, ale to jedynie ukrywa problem.

Zupełnie inaczej wygląda sytuacja zbyt słabych elementów w grze - są one zwykle przez graczy zupełnie ignorowane, a więc nie mają wpływu na grę. Nie wydają się one być ani szkodliwe, ani pożyteczne. Tworzenie takich elementów jest tylko marnowaniem zasobów, które można by przeznaczyć na coś innego.

W jaki sposób zachować równowagę w grze?

Najbardziej oczywistym sposobem na równoważenie gry jest modyfikowanie ich cech. Tutaj pojawia się jednak problem - czasem minimalna modyfikacja elementu sprawi, że z elementu zbyt silnego zmieni się w element zbyt słaby i na odwrót. Im więcej cech ma element, tym łatwiej go zrównoważyć, ponieważ można go modyfikować na więcej sposób - być może któryś z tych sposobów zmieni siłę elementu w sposób minimalny, dzięki czemu uda się zachować idealną równowagę.

Innym rozwiązaniem może być wprowadzanie synergii między elementami. Jeśli uznamy, że jakiś element jest zbyt słaby, to możemy dodać do gry element uważany za mocny ale tylko wtedy, gdy zagra się go razem ze wspomnianym słabym elementem. Takie rozwiązanie z pewnością może sprawić, że każdy element w grze będzie warty uwagi, ale nieodpowiednio zaimplementowane może doprowadzić do tego, że gra zacznie się sprowadzać do sztucznego podziału elementów na grupy, których gracze nie będą chcieli ze sobą mieszać. Jeśli w trakcie gry zobaczymy element z jakiejś "grupy synergii", to od razu będziemy się spodziewali pozostałych elementów z tej grupy, a więc gra byłaby mniej emocjonująca.

Kolejnym rozwiązaniem może być wprowadzenie do gry elementu papier-kamień-nożyce. Jeśli będziemy postępowali zgodnie z tym założeniem, to w grze każdy element będzie miał jakąś znaczącą wadę, którą przeciwnik może łatwo wykorzystać. Załóżmy, że jakieś taktyki A pokonują 90% innych taktyk (które nazwiemy taktykami B), co doprowadza do jej wysokiej popularności. W takiej sytuacji opłacalne byłoby stosowanie tych 10% taktyk, które nie byłyby wrażliwe na taktykę A (takie taktyki nazwiemy taktykami C). Jeśli taktyki C staną się popularne, to nieopłacalne stanie się stosowanie taktyk A, a to z kolei znowu pozwoli graczom na korzystanie z taktyk B i koło się zamyka. Schemat papier-kamień-nożyce sprawia więc, że opłacalność taktyk jest w pełni zależna od graczy i żadna z taktyk nie może być górująca tak długo jak posiada co najmniej jedną wadę.


Czym będę się kierować równoważąc Token Battle?

Token Battle będzie przede wszystkim opierało się na dostosowywaniu się sytuacji w grze. Każdy z graczy będzie miał dostęp do swojego zestawu żetonów, składającego się z kilku stosów żetonów i do każdego z tych żetonów będzie miał cykliczny dostęp. Każdy żeton będzie się sprawdzał w innych sytuacjach, a więc gracz będzie musiał dobierać żetony w taki sposób, aby się uzupełniały. Rozbieżność siły żetonów w zależności od sytuacji będzie różna, ale gracz będzie miał dostęp do wielu żetonów, więc opłacalność żetonów nie będzie zbyt zależna od stopnia jego ryzyka. 

ś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 tablicą losowych wartości z jakiegoś zakresu bez powtórzeń jest proste - wystarczy wszystkim elementom tablicy przypisać kolejne liczby, a następnie wystarczy użyć wbudowanej funkcji sortującej:

 for (int x = 0; x < SizePow; x++) {
  RNG [x] = x;
 }
 Array.Sort (RNG);
 for (int x = 0; x < MaxC; x++) {
  RNGCardOrder [x] = x;
 }

 Array.Sort (RNGCardOrder);

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łą: