Minden szoftvernek, ami némileg meghaladja a Hello World típusú példaprogramok méretét van belső állapota. Ez a belső állapot a memóriában létező objektumok összessége. Ezen számtalan objektumnak kell legyen valamilyen hierarchiája: Van amelyik fontosabb, mint a többi. Ha ez a hierarchia hiányzik, akkor nagy bajban vagyunk, ezért is most feltételezzük, hogy a képzeletbeli szoftverünk osztály/objektum hierarchiája létezik és jól átgondolt.
A hierarhiában nevezzük alacsony szintűnek az alapvető osztályokat és objektumaikat úgy, hogy egyre magasabb szintekre lépve egyre jelentéktelenebbek fognak szerepelni. Fordítva is válaszhatnánk ezt a terminológiát, de ebben a blogposztban ehhez fogom magam tartani, mivel ez a reláció jobban illeszkedik az épület-alap és a a fa-gyökér valós világ béli példáihoz.
Ha lelépkedünk a gyökérig ebben a hierarchiában elérve a legfontosab objektumot, akkor talán találunk valami olyasmit, mint egy állapotgép, ami néhány véges állapot közt váltogat. Egy játék esetében ezek az állapotok lehetnek pl.: Kezdőképernyő, Játékmenü, Pályaválasztó, Játék, Játék vége képernyő, Pause képernyő, Beállítás menü, Grafikai beállítás menü, stb…
Mint a fenti példa mutatja, ez a fő állapotgép nagyrészt a UI / UX navigációt fogja fedezni. Talán nem gondolunk erre gyakran úgy mint a legalapvetőbb részére a játéknak, de ha végiggondoljuk, akkor rájövünk, hogy mégis így van. Ez az az objektum, aminek a szoftver teljes futása alatt mindig léteznie kell.
Azt szeretném jelen blogposztban megosztani, hogy tapasztalatom szerint hogyan érdemes ezt az állapotgépet felépíteni ezen a lehető legalacsonyabb, azaz legfundamentálisabb szinten, ami a legtöbbször fedezi az igényeinket. Nem túl komplikált a válaszom, de sok kísérletezés és sok projekt erevménye, ami alapján kikristályosodott bennem, hogy ez a legcélszerűbb megközelítés a játékok sőt általánosabban a szoftverek nagyrészében.
Szóval az egyszerű válaszom, hogy az állapotgép legyen egy Stack, vagy (ha valaki nagyon ragaszkodik a magyar terminológiához,) egy verem.
Oké. Lehet, hogy én vagyok lassú, hogy évek kellettek, hogy ezt felismerjem, de az is biztos, hogy vannak még mások is akik ezzel a kérdéssel kűzdenek. Ezért is a jelen poszt.
Hogy miket pakolunk ebbe a verembe, ami reprezentálja az egyes fő szoftverállapotokat, az mellékes. Lehetnek referenciák összetettebb objetumokra, de egyszerű enum-ok is. Egyedül azért könyörgök az olvasónak, hogy ne használjon string-eket. Ha ilyesmi bárkinek is megfordulna egyáltalán a fejében, azonnal felejtse el! Nehezemre esik nem kifelyteni, miért olyan rossz ötlet ez, de most fókuszálnom kell a témára.
Unity projekt esetén használhatunk állapotnak Scriptable Objekt-eket, amiknek mindig megvan az az előnye, hogy kódolás nélkül is létrehozhatók, és ami mégfontosabb, igény esetén beállítások adhatók hozzá. Viszont ahogy korábban jeleztem, sok esetbnen egy enum bőven elég a célra.
Támogassuk meg a vermünket egy Observer patternnel, azzaz indítsunk felíratkozható eseményt, amikor bármi változás történt az adatszerkezetünkön. A releváns információk, amit az eseménynek továbbítani kell, az, hogy…
- push vagy pop művelet történt,
- mi került a vermünk tetejére,
- és mi volt ott (legfelül) előtte.
Végül hasznos lehet az alapvető Push, Pop és Peak műveleteken kívül még egy-két hasznos segítő metódust a veremhez adni, és már kész is vagyunk.
- bool Contains(State): Tartalmaz-e egy konkrét State-et lekérdezés
- void PopUntil(State): Visszalépés egy konkrét State-ig
Unity estében érdemes lehet, ha ez az állapotgép egy Don’tDestroyOnLoad Singleton MonoBehaviour. Ezáltal könnyen tudjuk menedzselni a játék indítását és kikapcsolását.
- Induláskor hozzáadjuk az üres veremhez a kezdőállapotot
- Befejezéskor egyenként felülről lefelé kivesszük a még veremben lévő elemeket.
De miért pont verem? Miért korlátozzuk le magunkat egy ilyen korlátolt adatstruktúrára?
- A veremmel egy memóriát építünk, amivel mindig tudjuk, hogy hova tudunk visszalépni. Nem csak a mostani állapotot tudjuk, hanem az összes lejjebbit is megfelelő sorrendben. Gondoljuk végig: egy menü, almenü, akár csak egy igen/nem kérdésbuborék is mindig automatikusan tudja, mi történik ha egyszerűen kilépünk belőle, hova kell mennie. Ezt a viselkedést komplikált lenne hagyományos állapotgéppel elérni.
- Egy State-nek nem csak aktív és nem-aktív állapota van, hanem még egy köztes lehetősége is mikor a State a veremben van, de nem legfelül. Úgy tapasztaltam, hogy ez meglepően hasznos lehet számtalan esetben. Pl.: egy olyan objektum, mint a játékos karakter, akkor szünteti meg magát, hogyha a Gameplay állapot kikerült a veremből, viszont hibernálja magát (nem csinál semmit), ha valami rákerült a Gameplay állapot tetejére a veremben, pl. egy Pause vagy arre még egy Settings menü.
- Többszörös visszalépkedés esetén nem ugorhatunk át állapotokat. Ez hasznos lehet, hogyha egy objektum egy állapotból való kilépés esetén szeretne maga után valamennyit takarítani.
- A verem korlátozó természete jelen esetben kifejezetten hasznos a felhasználói élmény szempontjából is. A verem egy egyértelmű UX flow-t ad a szoftvernek. Persze lehetne sokkal ugrálósabb is a program, de minek? Ha a játék videobeállítás menüjéből átugorhatunk a pályaválasztóba az lehet, hogy spórol 1-2 másodpercet a felhasználónak, de összezavarhatná a navigációban. Sokkal tisztább azt a logikát követni, hogy ha egy állapotba belépünk, abból ki is kell lépni valamikor.
- Illegális állapotlépéseket sok esetben automatikusan tudunk detektálni és jelezhetjük a fejlesztőnek, hogy valamit elrontott. (Pl. ha push-olni akarunk egy állapotot, ami már korábban szerepel a veremben.)
- Ékegyszerű ez a megoldás. Egyszerű implementálni, egyszerű átlátni és egyszerű egy debug tool-lal vizualizálni. (Nem kell irányított gráfokkal szórakozni.) Ezeket a tulajdonságokat szeretem látni a korábban említett fontossági osztály/objektum hierarchiám alján.
Mindezért manapság egy új projekt írásakor gondolkodás nélkül ezzel a patternnel indítok. Természetesen nem fogja ez az egy állapotgép lefedni minden igényünket az adott szoftver fejlesztésében, viszont magasabb szinteken egyéb hasonló vagy teljesen más viselkedésű állapotgépek bármikor beépíthetők. Ezt a legalsó elemet azonban legtöbb esetben hasznos megtartani olyan egyszeűnek, mint egy függőleges lyuk a földben.