Előfordul, hogy nem lehet egy műveletet elvégezni egy Unity üzenetmetódus-híváson belül, mint például a Start
, Update
vagy FixedUpdate
, mert a feladat hosszabb időt vesz igénybe.
Ha egy üzenetmetódusból nem lépünk ki, akkor a teljes program futása nem tud tovább haladni. Ezzel akár lefagyaszthatjuk a motort. Gondoljunk csak pl. egy végtelen ciklusra. Erről bővebben itt olvashatsz: MonoBehaviour-ok életciklusa
A legtöbb esetben azonban nem azért lenne egy művelet hosszabb, mert túl sok számítást igényel, hanem azért, mert késleltetve akarunk valamit elvégezni.
Például képzeljük el azt, hogy van egy mechanikánk a játékben, amit gyakran “Invincibility frames”-nek neveznek. Ennek lényege, hogy egy sebzés után egy rövid ideig sebezhetetlen a játékos karakterünk.
A sebzés megtörténtét leggyakrabban egy Update
, OnTriggerEnter
vagy OnCollisonEnter
metódusban tudjuk lekezelni. Ekkor több mindent tehetünk, levonhatjuk a megfelelő életmennyiséget, lejátszhatunk egy animációt vagy hangeffektet az eseményhez, valamint kikapcsolhatjuk a hitbox Collider-t, hogy néhány tizedmásodpercig ne tudja semmi sebezni a karaktert. Viszont itt a probléma. Nem tudjuk azt mondani, hogy most mondjuk az Update
metóduson belül megvárjuk, hogy leteljen az “Invincibility frames” ideje és utána visszakapcsoljuk a Collider-t. Ekkor az egész szoftvert lefagyasztanánk. Az adott MonoBehaviour metódusból muszáj kilépni hamar.
Milyen lehetőségeink vannak?
Késletetett lekezelés az Update metódusban
Egyik lehetőségünk, az hogy egy osztályváltozóban, field-be eltároljuk az időpontot, amikor az esemény megtörtént. Ezután egy olyan ciklikus eseménymetódusban, mint pl. az Update
folyamatosan ellenőrizzük, hogy lejárt-e a késleltetés ideje az esemény időpontjától számítva. Ezt a módszert polling-nak nevezzük.
A következőképp nézne ki a fenti megoldás megvalósítása egy MonoBehaviour-on belül:
[SerializeField] float invincibleTime = 0.5f; // Mennyi legyen a késleltetés?
float _damageTime; // Mikor történt az esemény (sebzés)
bool _isInvincible; // Éppen sebezhetetlenek vagyunk-e
void OnTriggerEnter(Collider other) // Megtörténik az esemény
{
GetComponent<Collider>().enabled = false; // Kikapcsoljuk a Collidert
_isInvincible = true; // Eltároljuk az esemény megtörténtét
_damageTime = Time.time; // Eltároljuk az esemény időpontját
}
void Update() // Minden Update-ben ellenőrizzük: Polling
{
if (_isInvincible && Time.time > _damageTime + invincibleTime) // Ha épp sebezhetetlen
{ // és lejárt az idő,
GetComponent<Collider>().enabled = true; // akkor visszakapcsoljuk a Collider-t
_isInvincible = false; // és eltároljuk, hogy már lekezeltük az eseményt.
}
}
Invoke
A fenti megoldás kicsit kényelmetlen, egy egyszerű feladat sok kódolással jár és nehezen kommunikálja le a megírt forráskód a programozói szándékot.
Helyette egy MonoBehaviour-on belülről használhatjuk az Invoke
metódust, ami késleltetve hajt végre egy műveletet. A fenti funkcionalitás az Invoke
segítségével a következőképp oldható meg:
[SerializeField] float invincibleTime = 0.5f; // Mennyi legyen a késleltetés?
void OnTriggerEnter(Collider other) // Megtörténik az esemény
{
GetComponent<Collider>().enabled = false; // Kikapcsoljuk a Collidert
Invoke(nameof(EnableCollider), invincibleTime); // Késleltetett hívás
}
void EnableCollider() // Saját metódus
{
GetComponent<Collider>().enabled = true; // Visszakapcsoljuk a Collider-t
}
Ekkor nem áll meg a Unity Update ciklusa, helyette a fenti a Unity-is egy polling elvű megoldást használ a háttérben, de számunkra sokkal tisztább és átláthatóbb a kód.
Az Invoke
metódus két paramétert vár, az első egy string
a hívandó metódus nevével, a második a késleltetés ideje. Erősen javasolt nem csak pusztán beírni a késleltetve hívni kívánt metódus nevét string formában, hanem helyette használhatjuk a nameof(…)
műveletet, amivel egy programozási elemnek tudjuk lekérni a nevét string
-ként. Lehet ez az elem egy osztály, metódus, változó vagy bármi egyéb.
Invoke("DelayedMethod", delayTime); // ❌ Ez is működik
Invoke(nameof(DelayedMethod), delayTime); // ✔️ De ez jóval hibatűrőbb
Így a késleltetendő metódus átnevezése esetén a nameof
-on belüli név is megváltozik.
Hogy erre miért fontos figyelni, arról bővebben itt: Objektumok azonosítása string-gel
Pár egyéb az kapcsolódó metódus:
// Többszörös ismétlődő végrehajtás:
// Paraméterek: Melyik metódust, Mennyi idő múlva, Mennyi időnként ismételve?
InvokeRepeating(nameof(Method), float delay, float repeatRate);
// Zajló végrehajtások lekérdezése
bool isInvoking = IsInvoking(nameof(Method)); // Zajlik-e egy adott invoke?
bool isInvoking = IsInvoking(); // Zajlik-e bármi késleltetett végrehajtás a MB-on?
// Megszakítás
CancelInvoke(nameof(Method)); // Adott késleltetett végrehajtás visszavonása
CancelInvoke(); // Minden késleltetett végrehajtás visszavonása
Megjegyzendő, hogy a Destroy
metódusnak létezik késleltetett változata is. Ezáltal tehát egy Unity objektum (komponens, GameObject…) törléséhez nem szükséges hívni az Invoke
-ot.
float delay = 1.5f;
Destroy(gameObject, delay);
Coroutine-ok
Képzeljük el, hogy nem csak azt szeretnénk, hogy egy műveletet hajtsunk végre bizonyos idő után, hanem egy egész sorozatát akarjuk végrehajtani késleltetett műveleteknek. Például nem csak ki akarjuk kapcsolni egy objektum Collider-ét és néhány tizedmásodperc múlva vissza, hanem mondjuk a köztes időben valamilyen logika szerint villogtatni is kívánjuk az objektumot.
Ehhez egy úgynevezett Coroutine-t (korutin-t) kell elindítanunk. A Coroutine-ok bizonyos értelemben kívül helyezkednek el az upate Cikluson és egy Coroutine-on belül bármikor megmondhatjuk, hogy álljon meg a futás és némi idő után fusson tovább.
void OnTriggerEnter(Collider other)
{
StartCoroutine(InvincibilityFrames());
}
IEnumerator InvincibilityFrames() // Egy Coroutine IEnumerator típusú
{
float time = Time.time;
collider.enabled = false;
Color color = spriteRenderer.color;
while (Time.time < time + invincibleTime)
{
spriteRenderer.color = Color.clear;
yield return new WaitForSeconds(flashTime); // Várj x másodpercik
spriteRenderer.color = color;
yield return new WaitForSeconds(flashTime);
}
collider.enabled = true;
}
(Valójában a Coroutine is a Unity Update cikluson belül fut, de számunkra, Unity fejlesztők számára olyan mintha egy teljesen új saját végrehajtási szálat kapnánk, amit bármikor leállíthatunk.)
Alább látható milyen módokon tudjuk leállítani egy Coroutine futását:
yield return new WaitForSeconds(flashTime); // Várj x ideig!
yield return new WaitForSecondsRealtime(flashTime); // A TimeScale nem számít
yield return new WaitForFixedUpdate(flashTime); // Várj a Fixed ciklusig!
yield return null; // Várj egy frame-et!
yield return new WaitForEndOfFrame(); // Ugyanaz mint az előző sor, csak lassabb.
yield break; // IEnumerator metódusból így lehet kilépni.
Egy elindított Coroutine-t eltárolhatunk egy változóba. Ekkor később akár meg is lehet szakítani az eltárolt Coroutine futását a következő módon:
Coroutine _coroutine; // Privát field
void OnTriggerenter(Collider other)
{
_coroutine = StartCoroutine(TestCoroutine()); // Elindítás és eltárolás
}
void OnTriggerExit(Collider other)
{
StopCoroutine(_coroutine); // Leállítás / Megszakítás
IEnumerator TestCoroutine()
{
yield return new WaitForSeconds(1);
Debug.Log("TestCoroutine");
}
Ha minden Coroutine-t le szeretnénk állítani, ami egy adott MonoBehaviour példányon fut, akkor a StopAllCoroutines()
metódust használhatjuk.
Start mint Coroutine
Ha a Start metódus IEnumerator
típusú, a Unity automatikusan Coroutine-ként hívja meg.
Tehát az alábbi kód helyett…
void Start()
{
StartCoroutine(MyCoroutine());
}
IEnumerator MyCoroutine()
{
for (int i = 0; i < 10; i++)
{
Debug.Log($"Counting: {i}");
yield return new WaitForSeconds(1);
}
}
….használható a következő egyszerűbb verzió is:
IEnumerator Start()
{
for (int i = 0; i < 10; i++)
{
Debug.Log($"Counting: {i}");
yield return new WaitForSeconds(1);
}
}