Egy Unity játékban leggyakrabban a kód túlnyomó részét MonoBehaviour osztályok alkotják. Ezen osztályok aztán komponensek formájában GameObject-hez csatolva érik el a célt, amire létrehozták őket. A MonoBechaviour életciklus metódusain van lehetőségünk hozzáférni elemi fontosságú eseményekhez és ezen metódusok csak akkor futnak le, ha létező GameObject-hez vannak csatolva.
Start()
Update()
LateUpdate()
FixedUpdate()
OnDrawGismos()
- …
A motor filozófiájának és logikájának központi fogalmai tehát ezek: GameObject és MonoBechaviour. Szinte mindent a szoftverünkben ezeken keresztül érdemes létrehozni.
Habár a játékmotor szempontjából minden MonoBehaviour osztály egyenlő, a mi fejlesztői szempontunkból érdemes lehet kategóriákba bontani őket. Minden osztály egyenlő, de vannak egyenlőbbek.
MonoBehaviour-ok szintjei
Gyakori mentális kategorizálása ezen komponenseknek:
- Globális menedzserek: Teljes játék szintű globális komponensek
- Scene Manager-ek: Egy Scene-re egyedi komponensek
- Gameplay komponensek: Egyes entitások viselkedését leíró szkriptek
- Támogató szkriptek: Szeparált játékelemek, Audio-vizuális visszacsatolást adó és UI szkriptek.
Egyszerre csak egy van belőlük és mindig ugyanaz az egy. Létezésük elengedhetetlen a teljes játék működése szempontjából. Általában DontDestroyOnLoad scene-ben vannak. Bővebben: Scene-ek betöltése
Egyszerre max egy van belőlük, de Scene-enként változhatnak. Lehetnek scene-ek, amikor nem kellenek.
Sok lehet belőlük egy scene-ben egyszerre, de akkor sincs gond, ha egy sincs. Játékmenet szempontjából hasznosak.
Nélkülük is játszható a játék, legfeljebb nem néz ki túl jól, vagy kevésbé élvezetes. Nem szólnak bele a játékmenet magjába.
(A nevekkel nem vagyok teljesen elégedett. Nyitott vagyok egyéb javaslatokra.)
A szintek logikája, hogy minden alacsonyabb szint függ a fölötte lévőktől, de magasabb szintek egyeltalán nem függnek az alacsonyabbaktól. Ez azt jelenti, hogy egy magasabb szintű osztályban sosincs semmilyen hivatkozás egy alacsonyabb szintűre. Erről bővebben később.
Példa
Vegyünk egy példát, hogy könnyebb legyen megérteni a szkriptek ezen szintjeit.
Képzeljünk el bármiféle lövöldözős játékot.
- Globális menedzserek:
SceneManager
/ Jelenet menedzser: Ez felel a jelenetek ki betöltésért, felülíró és additív módon.StatisticsManager
/ Statisztika menedzser: Ez felel a különböző statisztikák nyilvántartásáért.AudioManager
/ Hang menedzser: Ez felel a különböző hangtípusok hangerejéért.SettingMenu
/ Beállítás menü: Bármikor előhívtató beállítások menüje.- Scene Manager-ek:
TimeManager
/ Idő menedzser: Ez szabályozza az idő múlását egy pályán belül.ScoreManager
/ Pontszám menedzser: Ez felel az egy pályán belüli pontszámok kezeléséért.- GameManager / Játék menedzser: Ez felel a játék legalapvetőbb szabályainak betartatásáért. Mikor kezdődik a játék? Mikor ér véget? …
GameInputManager
/ Játék input menedzser: Ez felel a játékos inputjainak lekezeléséért.Player
/ Játékos: A játékost reprezentáló karakter szkriptjeGameCamera
/ Játék kamera: A játékmenet alatt a kamera mozgását szabályozó szkript.- Fő Gameplay komponensek:
Character
/ Karakter: Mindenki aki él és mozog a játékbanProjectile
/ Lövedék: Az aminek hangzikSpwner
/ Létrehozó: Automatukusan hoz létre valamilyen objektumokat. Pl.: ellenfél karaktereket- Támogató szkriptek:
PlayerHud
(Head Up Display) / Játékos kijelző: Ez felel a játékos feje felett megjelenő információk kiírásáért.Trap
/ Csapda: Sebez, eldob, vagy csinál valamit egy karakterrel, ha hozzáérFootstepSoundPlayer
/ - Lépés hang lejátszó: Ezt a komponenst, ha egy talaj elemhez adjuk, akkor egy bizonyos hangot lejátszik egy lépés hatására.HealthBarHud
/ Kéletcsík kijelző - Bármely élettel rendelkező objektum felett megjelenő életcsík.DamageParticleEffectPlayer
/ Sebzés részecske effekt lejátszó. Bármilyen sebezhető objektumhoz hozzáadható. Sebzés hatására automatikusan lejátszik egy részecskerendszer effektet.
Fontos kiemelni, hogy ez nem egy kőbevésett lista, csak egy lehetséges felosztása néhány gyakori szkriptnek. Lehet, hogy olyan szoftvert írunk, amiben nem csak egy játékos létezhet. Ekkor a Player
osztály a fő Gameplay komponensek kategóriába esne, mivel több is lenne belőle. Lehet, az is hogy, egy kreatív főmenüt csinálunk amiben jelen van a játékos. Ekkor a GameInputManager
az egész szoftverre kiterjedő globális menedzser lenne. És így tovább…
Referenciák
Abban különülnek el a fenti rétegek, hogy más módon érdemes kezelni a referenciáikat, máshogy érdemes hozzáférni.
A felső Globális Menedzser szint objektumai egyszer létrejönnek a szoftver indulásakor és annak teljes futása alatt folyamatosan léteznek is.
Ezeket a folyamatosan létező Globális Menedzser-eket érdemese a DontDestroyOnLoad Scene-be tenni. Erre írhatunk egy egyszerű scriptet:
using UnityEngine;
class DontDestroyOnLoadObject : MonoBehaviour
{
void Start() => DontDestroyOnLoad(gameObject);
}
A menedzserek mindegyike elérhető a FindObjectOfType()
metódussal ám gyorsabb és kényelmesebb elérést biztosít a Singleton tervezési minta. Ezáltal a MonoBehaviour menedzser típus egyetlen példányát eltároljuk egy statikus változóba, ami mindig rendelkezésünkre áll ezután. Ezen eltárolásért a példány maga felel. A megvalósításról bővebben. Singleton és Service Locator.
Ezen legfelsőbb szintű elemeknél érdemes elérni, hogy statikus singleton referenciájuk beállított legyen már azelőtt, hogy bármely egyéb típusnak lefutna az Awake()
vagy Start()
metódusa.
Ennek legegyszerűbb módja, ha magasabb pozícióba tesszük őket a végrehajtási sorrend listájában: MonoBehaviour-ok életciklusa.
A Scene szintű menedzserek abban különböznek a globálisaktól, hogy habár egyszerre egy létezik belőlük, a szoftver teljes futása alatt ez a példány cserélődhet és az sem garantált, hogy mindig létezni fog egy belőle. Például a főmenüben nem feltétlenül kell GameplayManager…
Ezért kapásból el is hagyhatjuk a DontDestroyOnLoad()
hívást rájuk.
A Scene szintű menedzserek szintén elérhetők lehetnek Singleton pattern-nel, azonban ezen esetekben figyeljünk arra, hogy a menedzser referenciát, mindig közvetlenül a singleton példányon keresztül érjük el. Egyéb komponensek sose tárolják el egy saját field-be őket, hiszen csere esetén ezek az extra field-ek nem kerülnek frissítésre.
Az egyes típusú fő gameplay komponensek referenciái elérhetők a FindObjectsOfType()
csoportos lekérdezéssel, de megint csak gyorsabb és kényelmesebb az önregisztráció módszerét használva statikus listában eltárolni egy típus minden példányát. Ebben az esetben a létrejövő és törlődő elemek egyenként felelnek a saját be- és kiregisztációjukért:
Bővebben: Önregisztráció
Azt is meghatározza ez a kategorizálás, hogy az egyes osztályok hogyan ismerhetik egymást, hogyan függhetnek a másiktól.
A osztály függ egy másik B osztálytól, ha A osztály kódja bármi módon hivatkozik B osztályra, például változó lekérdezésen, függvényhíváson vagy bármi egyéb módon keresztül.
Tehát ha törölnénk B osztályt akkor A osztályon belül fordítási idejű hibák jelennének meg. Fordítva ez nem igaz.
Ezt más néven úgy mondjuk, hogy A osztály ismeri B-t.
Lehetséges, hogy A és B kölcsönösen függnek egymástól.
Az alsó szintű támogató szkripteket úgy írjuk meg, hogy magasabb szintű MonoBehaviourok sosem függjenek tőlük, ne ismerjék őket. A kommunikációt mindig ezen alacsonyszintű elemek kezdeményezzék metódushívással vagy pedig eseményre történő feliratkozással.
A magasabb szintű rétegek tartalmazzák a szoftver magját, amik elengedhetetlenek a játékunk szempontjából. Ha tartjuk magunkat ahhoz, hogy az alacsonyabb rétegek elemei ismerhetik csak a magasabbakat, akkor ha hibát vétünk az alacsonyabb rétegeken az nem tudja elrontani a fontosabb kódot. Ellenkező esetben egy alacsony szintű szkript kedvéért bele kellene nyúlnunk egy fentibe, kockáztatva azt, hogy valami hibát követünk el a kód létfontosságú részében, amitől sok minden más is függ.
A támogat szkriptek általában GetComponent()
, TryGetComponent()
hívásokkal valamint manuálisan [SerializeField]
bekötéssel érik el az egyes GamePlay objektumokat.
Loose coupling / Laza csatolás
Azt is érdemes megemlíteni, hogy sokszor a szoftverünk egy olyan elemi létezőit, mint például a játékos nem is csak egy darab Player
script képviseli, hanem felbontjuk annak funkcionalitását több kisebb felelősségre. Ezzel a hierarchiánk bonyolultabb lesz. A fő Player szkript magasabb szinten lesz, mint az egyes funkcionalitások.
A komponenseink mindig egy jól körül határolható célért feleljenek: Ezt a programozásban Single responsibility Principle-nek nevezzük (Egyetlen felelősség elve). Ha ezt a szabályt betartjuk, akkor egy osztálynak általában kevés egyéb típust kell egyszerre ismernie. Gondoljuk át mindig ezen ismeretségeket, próbáljuk minimaizálni őket.
Egy szkript maximum egy-néhány egyéb saját típust ismerjen! Emellett ezen ismeretségek illeszkedjenek egy jól átgondolt faszerkezetbe, ahol a csomópontok a szkriptek és egy az összekötő vonalak az ismeretségek. Próbáljuk ezt a gráfot egyszerűnek megtartani. Ehhez gyakran szükséges lehet abszrakciók bevezetése (Interface-ek (Hamarosan), Absztrakció, Polimorfizmus (Hamarosan) )
Az olyan szoftvereket, amik betartják ezt az elvet lazán csatolt szoftvereknek nevezik. A laza csatoltság lényege hogy átlátható, hibatűrő és moduláris legyen a programkód.
Ha túl sok a kapcsolat, akkor minden mindennel összefügg. Ekkor egy hiba felderítése sokkal nehezebb, hisz bárhonnan jöhet. Emellett olyan súlyos hibákat elkövetni is könnyebb, amik érintik a kódbázis magját.
Ha cserélni szeretnénk bizonyos elemeit a kódunknak, az is sokkal könnyeb, ha a kapcsolatok hálója letisztult. Ekkor csak egy két helyen kell átírni a kódbázist és nem az egészet.