Unity3d KopiPasta – czyli odtwarzamy ikoniczne bronie i mechaniki z legendarnych gier.

Dzisiejszy odcinek: Gravity Gun z Half-Life 2

Słówko o historii

Half-Life 2. Gra debiutująca w roku 2004, będąca sequelem genialnej i innowacyjnej strzelanki. Typowa mechanika, została rozwinięta o zaawansowane AI przeciwników, świetną grafikę, poważną i bardzo ciekawą fabułę. Jednak wszystkie te wspaniałości przyćmiła jedna rzecz. Dokładnie jedna z broni dostępnych w naszym aresenale.

Gravity Gun z Half-Life 2 Źródło: http://smod.wikidot.com/
Gravity Gun z Half-Life 2
Źródło: http://smod.wikidot.com/

Gravity Gun. Broń, która pozwalała w niesamowity sposób bawić się bardzo rozwiniętą (jak na te czasy) fizyką gry. Broń miała dwa tryby. Po naciśnięciu lewego klawisza myszy mogliśmy przyciągać obiekty do siebie, po czym naciskając klawisz ponownie wystrzelić ją z pewną siłą (zależną od tego ile trzymaliśmy klawisz). Klikając prawy klawisz myszy, mogliśmy odpychać obiekty znajdujące się przed nami. Gdy trzymaliśmy jakiś obiekt, prawym klawiszem zwalnialiśmy obiekt.

Broń niemal legendarna. Dzisiaj skopiujemy ją sobie.

Przygotowanie

Co nam potrzebne do zrobienia takiej zabawki? Ze standardowych assetów, będziemy potrzebować paczek Prototyping i Characters. Pierwsza do ustawienia jakiegoś podłoża, druga, żeby zabrać z niej First Person Controller.

Do tego na scenie trzeba ułożyć kilka obiektów (cube, sphere), oraz dodać naszej postaci broń. Ja użyłem karabinu z AssetStore.

Teraz musimy przygotować sobie dodatkowy Tag [Edit -> Project Settings -> Tags and Layers]. W panelu Inspector pojawia się nam panel tagów i warstw. Rozwijamy sobie paletę Tags i dodajemy nowy tag canGrab – jeżeli okienko do wpisywania nie jest dostępne od razu, wystarczy nacisnąć plusik.

Dodajemy Tag
Dodajemy Tag

Gdy mamy już tag, wystarczy dodać go do kilku obiektów na scenie (cube, sphere). Nie do wszystkich, abyśmy mieli porównanie między obiektami podatnymi na Gravity Gun, a tymi niepodatnymi. Tag dodajemy zaznaczając obiekt, a następnie w panelu inspector wybierając tag z listy.

Ustawiamy tag wybranemu obiektowi
Ustawiamy tag wybranemu obiektowi

Kolejny krok, to dodanie broni jako childa obiektu FirstPersonCharacter. Następnie trzeba go ustawić, w naturalny dla strzelanek sposób (tak, żeby był widoczny w kamerze) – nie podaję tutaj parametrów, bo najwygodniej do dostosować sobie samemu, mając włączony podgląd z kamery.

Przedostatni krok, to stworzenie nowego skryptu (w panelu Project klikamy prawym klawiszem myszy na wolnym polu i wybieramy Create -> C# Script – ja swój nazwałem GravityGun. Skrypt od razu dodajemy do obiektu FirstPersonCharacter.

Ostatnim krokiem, będzie dodanie collidera do obiektu FirstPersonCharacter. W tym celu zaznaczamy ten obiekt i wybieramy [Component -> Physics -> Box Collider]. Włączamy mu opcję IsTrigger i ustawiamy parametry: Center (1, 0, 3), Size (4, 2, 4).

Teraz ew. można dodać sobie materiały, żeby pozaznaczać kolorem, który obiekt można podnieść, a który nie. Sam zrobiłem to kolorem zielonym i czerwonym. Aby stworzyć taki materiał klikamy w panelu Project prawym klawiszem myszy na wolnym polu i wybieramy Create -> Material. Zaznaczamy nowy materiał i klikamy w kwadracik z kolorem, po czym wybieramy sobie dowolny kolor. Aby taki materiał nanieść na obiekt, wystarczy go na niego przeciągnąć.

Przykładowa Scena
Przykładowa Scena

Tworzymy

Całość uda się nam obsłużyć jednym skryptem. Do tego dość prostym.

Zaczynamy od deklaracji zmiennych:

private Vector3 fwd;
private RaycastHit hit;
private bool haveObject = false;
private GameObject grabbedObj;
private Vector3 newPosition;
private Camera cam;
private List<Collider> TriggerList;
	
public float maxForce = 1000;
public float selectedForce = 300;
public float forceGrowth = 400;

Prawdopodobnie, Twój skrypt zaczyna się burzyć o Listę. Dzieje się tak, ponieważ nie dodajemy odpowiedniej biblioteki i kompilator nie wie co to jest. Dlatego uzupełniamy sobie nasz wstęp pliku:

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

Pierwsze dwie linijki powinny być ustawione domyślnie. Dopisujemy tylko trzecią.

Ale wróćmy na chwilę do zmiennych. Po co one są? fwd do wektor określający przód obiektu. Przyda się nam przy celowaniu. RaycastHit to obiekt, określający trafienie za pomocą rzucania promienia. Zmienna bool określa czy trzymamy aktualnie obiekt. grabbedObj wykorzystamy do zapamiętania obiektu, który trzymamy. newPosition, to zmienna pomocnicza do określania pozycji obiektu. cam to kamera, a TriggerList to lista obiektów, które znajdują się w triggerze – to się rozjaśni później.

Ostatnie 3 zmienne to siła z jaką rzucamy obiektem. maxForce to maksymalna siła, selectedForce to siła wybrana przez gracza i forceGrowth to przyrost siły przy trzymaniu klawisza.

Teraz spory fragment kodu trafi do funkcji Update. Będę go omawiał fragmentami

if(Input.GetButtonUp("Fire1")) {
	if(!haveObject) {
		fwd = transform.TransformDirection(Vector3.forward);
		if (Physics.Raycast(transform.position, fwd, out hit)) {
			if(hit.transform.tag == "canGrab") {
				hit.transform.position = Vector3.Lerp(hit.transform.position, newPosition, Time.deltaTime);
				grabbedObj = hit.transform.gameObject;
				grabbedObj.GetComponent<Rigidbody>().useGravity = false;
				grabbedObj.GetComponent<Rigidbody>().isKinematic = true;
				haveObject = true;
			}
		}
	} else {
		grabbedObj.GetComponent<Rigidbody>().useGravity = true;
		grabbedObj.GetComponent<Rigidbody>().isKinematic = false;
		grabbedObj.GetComponent<Rigidbody>().AddForce(grabbedObj.transform.forward * selectedForce );
		haveObject = false;
		grabbedObj = null;
		selectedForce = 300;
	}
}

Pierwszy if sprawdza czy gracz nacisnął przycisk myszy. Dokładniej łapany jest tutaj moment puszczenia klawisza. Dzięki temu w oddzielnym ifie obsłużymy przytrzymanie klawisza myszy do określenia siły wystrzału.

Kolejne bloki if-else sprawdzają czy aktualnie trzymamy obiekt czy nie. Tutaj przypomnę. Gdy trzymamy obiekt to chcemy go wystrzelić. Jeśli nie, chcemy przyciągnąć.

Zaczniemy od sytuacji gdy nie mamy obiektu – przypominam, że wykrzyknik (!) oznacza negację, czyli wejdziemy do ifa, gdy zmienna haveObject ma wartość false.

Pierwszy krok to określenie przodu. Funkcja TransformFromDirection sprawia, że lokalne położenie obiektu zostanie zmienione na globalne (skrypt jest childem innego obiektu. Więc jego położenie ustawiane jest względem rodzica. My potrzebujemy pozycji względem świata).

Kolejny if, wykrywa trafienie rzutowania promienia (jest to metoda wykrywania kolizji. Wystrzeliwujemy promień z pozycji w pierwszym parametrze, w kierunku określonym w drugim, a jeśli w coś trafimy, to dane o tym obiekcie, jak i samej kolizji trafiają do zmiennej hit.

Jeżeli w coś trafiliśmy, sprawdzamy czy trafiony obiekt posiada tag “canGrab” – czyli czy jest dla nas interesujący, bo to obiekty z takim tagiem chcemy móc podnosić.

Jeżeli tak, wykonujemy szereg funkcji. Po pierwsze przenosimy obiekt z jego obecnej pozycji, na nową. Obecnie tego nie widać w kodzie, ale newPosition to pozycja kamery + kilka jednostek, dzięki temu obiekt unosi się nieco przed graczem. Funkcja Lerp sprawia, że pozycja obiektu jest powoli zmieniana od wartości początkowej do końcowej. Dzięki temu, mamy płynne przeniesienie obiektu, zamiast teleportacji.

Kolejny krok to wyłączenie grawitacji i włączenie opcji isKinematic. Dzięki temu obiekt będzie swobodnie za nami latał, bez dodatkowej rotacji czy innych niezamierzonych ruchów. Na koniec ustawiamy zmienną haveObject na true. No bo mamy obiekt.

Druga opcja – czyli posiadanie obiektu, wyłącza opcję isKinematic i włącza grawitację. Dzięki temu, gdy wystrzelimy obiekt, zacznie on zachowywać się zgodnie z zasadami fizyki.

Kolejny krok to wystrzelenie obiektu, służy do tego funkcja AddForce. Można to sobie wyobrazić, jako jednorazowe kopnięcie obiektu – tak jak kopnięcie piłki. Raz przykładamy siłę, po czym dalszy los piłki zależy od fizyki i tego na co trafi po drodze. Musimy tutaj tylko przypilnować, żeby wystrzelić obiekt w dobrą stronę. Tutaj jest to przód obiektu. Możemy to zrobić, dzięki odpowiedniemu manewrowaniu obiektem – wyjaśni to kolejny fragment kodu. Ostatnie kroki to zerowanie zmiennych.

if(haveObject) {
	newPosition = cam.transform.position + (cam.transform.forward * 2);
	grabbedObj.transform.position = newPosition;
	grabbedObj.transform.rotation = Quaternion.Lerp(transform.rotation, cam.transform.rotation, Time.deltaTime);
	if(Input.GetButton("Fire1") && selectedForce < maxForce) {
		selectedForce += Time.deltaTime * forceGrowth;
	}
}

Kolejny if znajdujący się w funkcji Update. Reaguje tylko gdy mamy obiekt. Lecimy po kolei. Pierwsza linia ustawia nam pozycję chwyconego obiektu, tak by był nieco przed kamerą. W drugiej linii tą pozycję przypisujemy do pozycji obiektu. Dzięki temu, chwycony obiekt zgodnie podąża za kamerą.

Dodatkowo, pochwycony obiekt obracamy tak, aby jego rotacja była zgodna z rotacją kamery. Teraz wyjaśnia się, dlaczego wystrzeliwując obiekt, możemy go wystrzelić po prostu do przodu. Ta linia kodu, odpowiada za to, że przód obiektu, jest zawsze tam gdzie przód kamery. Znów odpowiada za to funkcja Lerp – tylko tym razem pochodząca z klasy Quaternion, zamiast Transform – bo operujemy na rotacji – kątach, a nie na pozycji – wektorach.

Wewnętrzny if, to nic innego niż nabieranie mocy w czasie trzymania klawisza myszy. Gdy trzymamy klawisz strzału, a wybrana moc jest mniejsza od maksymalnej dostępnej zwiększy wybraną moc. W sumie tyle.

Fukcję Update kończy ostatni if:

if (Input.GetButtonDown ("Fire2")) {
	if(!haveObject) {
		foreach(Collider other in TriggerList) {
			Vector3 dir = other.transform.position - transform.position;
			other.GetComponent<Rigidbody> ().AddForce (dir * (maxForce / 3));
		}
	} else {
		grabbedObj.GetComponent<Rigidbody>().useGravity = true;
		grabbedObj.GetComponent<Rigidbody>().isKinematic = false;
		haveObject = false;
		grabbedObj = null;
		selectedForce = 5;
	}
}

Mamy tutaj reakcję na prawy klawisz myszy. Jeśli nie mamy obiektu, chcemy wykonać odepchnięcie. Funkcja foreach przelatuje wszystkie obiekty we wskazanej kolekcji (tablicy, liście itp.  w naszym wypadku jest to lista). Co robimy wewnątrz pętli? Najpierw określamy kierunek w którym należy przyłożyć moc. Chcemy symulować stożkowe rozchodzenie się mocy. Wystarcza nam do tego uzyskanie wektora, który określamy w najprostszy możliwy sposób. Odejmując współrzędne końca wektora od jego początku.

Wektor liczymy dla każdego obiektu oddzielnie, bo każdy wektor jest inaczej położony względem gracza. Gdy mamy wektor, wystarczy użyć znanej nam funkcji AddForce. Jednak tym razem odpychamy obiekty ze stałą wartością siły.

Gdy naciśniemy drugi klawisz myszy, ale mamy obiekt, chcemy go tylko wypuścić. W tym celu wykonujemy te same ruchy co przy wystrzale, tyle że bez wystrzału.

OK. Wszystko niby jest, ale w pewnym momencie pracujemy na kolekcji. Kolekcja ta jest pusta, więc trzeba ją jakoś uzupełnić. W tym celu mamy dwie funkcję:

void OnTriggerEnter(Collider other)
{
	if(!TriggerList.Contains(other) && other.tag == "canGrab")
	{
		TriggerList.Add(other);
	}
}

void OnTriggerExit(Collider other)
{
	if(TriggerList.Contains(other))
	{
		TriggerList.Remove(other);
	}
}

Jeżeli jakiś obiekt wejdzie w trigger sprawdzamy dwie rzeczy. Czy ma odpowiedni tag i czy nie mamy go już w kolekcji. Jeśli obiektu nie ma i ma odpowiedni tag, to go dodajemy.

Gdy jakiś obiekt opuszcza trigger, to usuwamy go z kolekcji.

Niby wszystko ładnie, ale skrypt dalej będzie rzucał błędy. Dlatego, że kolekcję mamy, ale jej nie zainicjowaliśmy. W tym celu dodajemy jeszcze funkcję Start:

void Start()
{
	cam = GetComponent<Camera>();
	TriggerList = new List<Collider>();
}

Inicjujemy tutaj listę. Teraz zamiast być zwykłą deklaracją zmiennej, jest faktycznie istniejącą pustą listą. Dodatkowo do zmiennej dodajemy obiekt kamery. Korzystamy z niego parę razy i wyszukiwanie jej zawsze za pomocą GetComponent bardziej obciąża procesor – dlatego korzystniej zapisać ją sobie do zmiennej.

Została jeszcze jedna rzecz. Skrypt jest dobry, ale nie zadziała. Przez to, że do postaci dodaliśmy Collider w funkcji IsTrigger, nasze rzucanie promienia zawsze trafi w ten Trigger. Dlatego musimy wyłączyć wykrywanie trafiania w Triggery. Robi się to bardzo prosto. Wchodzimy w opcję: [Edit -> Project Settings -> Physics]. W menu, które pojawi się w Inspectorze odznaczamy opcję Raycast Hit Triggers.

Odznaczamy trafianie RayCastem w Trigger
Odznaczamy trafianie RayCastem w Trigger

Done!

 

Co jeszcze można?

Jeżeli ktoś chciałby bardziej dopieścić ten projekt, to dobrym pomysłem byłoby dodanie efektów cząsteczkowych, czy wibracji broni, aby oddać efekt ładowania mocy. Może wzbijający się kurz (efekty cząsteczkowe) przy odepchnięciu? No i oczywiście dźwięki działania broni.

Jeżeli pomysł na cykl się wam podoba i uważacie, że są inne fajne mechaniki/bronie, które można uznać za legendarne i warte odtworzenia to wiecie gdzie się pisze komentarze.

Podoba Ci się? Udostępnij!