Unity3d QuickTip – czyli szybkie porady, rozwiązania częstych problemów i sztuczki w Unity3d!

Dzisiejszy odcinek: Minimapa z własną teksturą

Uwaga! Jest to poradnik typu QuickTip. Zatem skupia się on na osiągnięciu założonego celu. Zatem zakładamy że użytkownik zna na tyle program Unity3d, aby samodzielnie wykonać najprostsze czynności, jak np. dodanie modelu kostki do sceny czy dodanie modelowi jakiegoś komponentu. Jeżeli brakuje Ci tej podstawowej wiedzy, zapraszam do tutoriala:
Unity Tutorial – Podstawy

Teoria

Minimapy. Czyli mały kwadracik najczęściej w prawym dolnym rogu, który wspomaga gracza w rozbudowanych światach. Kiedyś budowaliśmy taką mapę w oparciu o kamerę, co było niby spoko, ale trochę brzydkie i niepraktyczne. Dużo częściej stosuje się mapy z teksturą jako podkładką. Mamy dwa warianty takich map. Albo widzimy mapę w całości oraz ikonę gracza przesuwającego się po niej, albo gracza w jednym miejscu i poruszającą się mapę.

Minimapa w GTA
Minimapa w GTA
Mapa w grze Gothic II
Mapa w grze Gothic II

My zrobimy sobie obie wersje.

Przygotowanie

Do zrobienia takiej minimapy potrzeba nam kilku rzeczy:

  • Terenu gry
  • Gracza
  • Tekstury mapy
  • Tekstury reprezentującej gracza na mapie

Aby życia wam nie utrudniać:

UnityQuickTip_27 – Paczuszka ze wszystkim co się może przydać.

Wyświetlamy teksturę mapy

Pierwszym krokiem naszej zabawy, będzie wyświetlenie samej mapy. Dałem wam gotowy przykład, ale szybko objaśnię, jak sobie taką teksturę przygotowałem. Otóż zrobiłem sobie screen kompletnej mapki z gry:

Screen mapy gry z Unity
Screen mapy gry z Unity

Następnie w Photoshopie (lub innym programie graficznym) nakładamy sobie w odpowiednich miejscach odpowiednie kolory, symbolizujące różne elementy. U mnie ciemny to teren do chodzenia, a jasny szary to góry.

Aby wyświetlić samą mapę, nie musimy się za bardzo natrudzić. Tworzymy sobie najpierw skrypt (u mnie MapController.cs). Dodajemy go od razu do gracza. Zaczynamy od deklaracji kilku zmiennych:

public GUIStyle miniMap;
public Texture2D playerTexture;

private Rect mapPosition;

public float mapMargin = 10;
public float mapSize = 0.1f;

Możemy sobie teraz szybko wrócić do Unity i jako zmienną playerTexture przypisać teksturę dla gracza. Zmienna miniMap będąca zmienną typu GUIStyle wydaje się bardziej skomplikowana, bo ma masę opcji. Ale nas interesuje tylko jedna, czyli parametr Background dla opcji Normal. Wstawiamy tam teksturę mapy.

Ustawiamy teksturę mapy
Ustawiamy teksturę mapy

Teraz czas obrać odpowiednią pozycję mapy:

void Start () {
	mapPosition.width = Screen.width * mapSize;
	mapPosition.height = Screen.width * mapSize;
	mapPosition.x = Screen.width - mapPosition.width - mapMargin;
	mapPosition.y = Screen.height - mapPosition.height - mapMargin;
}

mapPosition ma typ Rect, więc będziemy mogli go bezpośrednio wykorzystać przy umiejscawianiu mapy. Wysokość i szerokość, to u mnie szerokość ekranu. Czemu? Bo chce mieć kwadratową mapę, dlatego w obu przypadkach daje ten sam wymiar. Mnożę go przez mapSize – teraz się wyjaśnia, czemu przy deklaracji ta zmienna była ułamkiem. W ten sposób ustalamy jaki procent szerokości ekranu, będzie stanowić mapa.

Pozycja x i y mapy, to szerokość/wysokość ekranu minus szerokość/wysokość mapy i margines. Co nam to daje? Pamiętajmy, że pozycje x i y w przypadku GUI to odległość lewego górnego rogu obrazka, od lewego górnego rogu ekranu. Jeśli podamy jako x samą szerokość ekranu, cała mapa wyląduje za ekranem. Odjęcie szerokości mapy sprawi, że mapa się pojawi, a dodatkowy margines jest tylko po to, żeby mapa nie stykała się z końcem ekranu.

Samo narysowanie mapy załatwia ten kod:

void OnGUI() 
{
	GUI.BeginGroup(mapPosition, miniMap);
	
	GUI.EndGroup();
}

Tworzymy sobie grupę o pozycji ustalonej wcześniej, oraz ze stylem zawartym w zmiennej miniMap. Ustaliliśmy tam jako tło, teksturę mapy. Dzięki temu cała grupa będzie miała tło mapy. Co tutaj zyskujemy? Jeśli narysujemy coś wewnątrz grupy i przesuniemy mapę w lewy górny róg, nie musimy modyfikować ustawień tego, co rysujemy na mapie, bo zachowa się to jako zawartość grupy.

Teraz czas na mapie, narysować gracza.

Obiekt gracza

Dodajemy nowe zmienne:

public GUIStyle miniMap;
public Texture2D playerTexture;
public Terrain terrain;

public float mapMargin = 10;
public float mapSize = 0.1f;

private Rect mapPosition;
private Vector3 terrainSize;
private Vector3 terrainPosition;

Teren i dwie pomocnicze zmienne. Można szybko skoczyć do Unity i dodać teren do zmiennej. Może być to nieco mylące. Wszystko zaraz się wyjaśni. W funkcji start dopisujemy dwie linijki:

void Start () {
	mapPosition.width = Screen.width * mapSize;
	mapPosition.height = Screen.height * mapSize;
	mapPosition.x = Screen.width - mapPosition.width - mapMargin;
	mapPosition.y = Screen.height - mapPosition.height - mapMargin;
	terrainSize = terrain.terrainData.size;
	terrainPosition = terrain.GetPosition ();
}

Pierwsza linia zapewnia nam informację o wymiarze terenu. Jego długość, szerokość i wysokość w jednostkach długości. Druga linia zapewnia informację o położeniu terenu w świecie gry – czyli współrzędne X, Y, Z.

No i czas na samo rysowanie gracza:

void OnGUI() 
{
	float px = (mapPosition.width * (transform.position.x - terrainPosition.x)) / terrainSize.x;
	float py = mapPosition.height - (mapPosition.height * (transform.position.z - terrainPosition.z)) / terrainSize.z;

	GUI.BeginGroup(mapPosition, miniMap);
		GUI.DrawTexture (new Rect (px, py, 10, 10), playerTexture);
	GUI.EndGroup();
}

Samo korzystanie z DrawTexture jest proste i oczywiste, nie ma za bardzo co tutaj tłumaczyć. Podajemy pozycję i rozmiar tekstury, oraz samą teksturę, którą zdefiniowaliśmy na początku. Za to zagadkowe mogą być obliczenia px i py.

Zacznijmy od prostego rysunku, który może nakreśli naszą sytuację:

Zmiana skali
Zmiana skali

Po pierwsze musimy pobrać położenie gracza w świecie i jakoś przekształcić je na położenie na mapie. Chcemy to zrobić niezależnie od rozmiaru mapy i terenu. Drugi problem jest taki, że osie nam się nie pokrywają – tzn. oś x jest OK. Ale nasza oś Y jest, nie tylko osią Z, ale do tego ma przeciwny zwrot.

Zaczniemy od tego jak obrać położenie X. Całość to prosta proporcja:

Pozycja Gracza w Świecie – Rozmiar Terenu
Pozycja Gracza na mapie – Rozmiar Mapy

Z tego otrzymujemy:
Pozycja Gracza na mapie = (Rozmiar Mapy * Pozycja Gracza w Świecie) / Rozmiar Terenu
Wszystkie dane mamy. Ale jest jeden haczyk. Teren nie koniecznie musi być zlokalizowany w punkcie (0, 0, 0). Dlatego, aby wszystko działało od pozycji gracza odejmujemy pozycję terenu. Dzięki tej translacji, gracz będzie miał określoną pozycję tak, jak by teren był zlokalizowany w punkcie (0, 0, 0), co jest dla nas wygodne.

W przypadku osi Y, musimy operować osią Z. W Unity osi Y dla GUI odpowiada oś Z w świecie gry, gdy pracujemy na 3 wymiarach. Ale skąd się bierze odejmowanie od wysokości mapy na początku? Przez obrócenie wektora osi między światem i GUI. Tam gdzie kończy się GUI, w świecie mamy 0 i odwrotnie.

Np. jeśli mamy mapę 200px, to tam gdzie w GUI mamy pozycję: (30, 200) to w świecie gry, będzie to lokacja (30, y, 0). Dlatego odejmujemy od maksymalnej wartości wyliczoną pozycję, otrzymując faktyczne położenie gracza, zamiast lustrzanego odbicia.

Trochę to skomplikowane, ale tylko matematycznie.

A co z dużą mapą?

No właśnie. Co jeśli napisaliśmy nowe GTA, mamy ogromny świat i nie chcemy wyświetlić całej mapy, gdzie gracz jest wielkości piksela? Potrzebujemy wtedy zmienić tylko kilka rzeczy. Zmieni się nieco zasada działania. Zamiast skalować mapę, pokażemy graczowi jedynie jej fragment. Po pierwsze, dodajemy sobie dodatkową zmienną:

public Texture2D mapTexture;

Oczywiście jest to tekstura mapy, którą należy uzupełnić z poziomu Unity.

Teraz modyfikujemy tylko funkcję OnGUI:

float px = (mapTexture.width * (transform.position.x - terrainPosition.x)) / terrainSize.x;
float py = mapTexture.height - (mapTexture.height * (transform.position.z - terrainPosition.z)) / terrainSize.z;

Na pierwszy rzut oka, zmiana może być nie widoczna. Ale zmieniamy mapPosition.width i mapPosition.height na mapTexture.width i mapTexture.height. Teraz naszym punktem odniesienia nie jest wymyślony przez nas rozmiar minimapy, tylko cała tekstura.

Następnie samo rysowanie:

GUI.BeginGroup(mapPosition );
	GUI.DrawTexture( new Rect(-px + 80, -py + 80 , mapTexture.width, mapTexture.height ), mapTexture );
	GUI.DrawTexture (new Rect (mapPosition.width / 2 - 5, mapPosition.height  / 2 - 5, 10, 10), playerTexture);
GUI.EndGroup();

Najpierw usuwamy styl z grupy. Całość dalej trzymamy w grupie, która określa położenie wyświetlania minimapy i rozmiar minimapy w pikselach. Styl usuwamy, ponieważ, aby “wycinanie” zadziałało, teksturę mapy musimy wyrysować oddzielnie. Jak widać, podajemy całkowity wymiar tekstury mapy jako parametry. Manewrujemy jedynie pozycją tekstury. Przez to, że grupa jest ograniczona, zawsze pokazuje jedynie wycinek całej tekstury, my przesuwając teksturę zmieniamy wyświetlany fragment. Dlatego położenie tej tekstury uzależnione jest od wyliczenia położenia postaci gracza. Ujemna wartość występuje dlatego, że musimy przenieść mapę po za obszar wyświetlania. Dodatkowe 80, to jakiś margines błędu. Nie udało mi się ustalić z czego wynika.

Druga linia to wyrysowanie postaci gracza na środku miejsca na mapę – czyli na środku grupy.

Problem pojawia się przy krawędziach mapy. Jeśli gracz do niej dotrze, tekstura zwyczajnie zniknie zostawiając puste miejsce. Rozwiązaniem tego, jest stworzenie odpowiednio większej tekstury mapy, tak aby graczowi zawsze coś się wyświetlało.

Uprzedzając pytania:

Jeśli chodzi o dodanie np. przeciwnika, zrobi się to analogicznie. Musimy tylko znać jego pozycję.

Jeśli chcecie mieć mapę na cały ekran jak w Gothicu, trzeba jedynie przeskalować teksturę na 100% ekranu. Dzięki procentowej wartości zmiennej mapSize, można to zrobić bardzo prosto – wystarczy ustawić tą wartość na 1 i uzależnić wyświetlanie mapy od naciśnięcia przycisku.

Czy da się do gracza dodać rotację? Da się, ale animacją. GUI nie obsługuje rotacji tekstur.