Table of Contents

2. Sisteme Turn-Based și Evenimente

Cerinte

Realizarea unui battle arena turn-based

Pentru simplitate, toate interactiunile se pot realiza prin butoane de comanda, dar este necesar sa folositi un sistem de evenimente pentru decuplarea responsabilitatilor.

Bonusuri:

Cateva exemple, doar ca idee de inspiratie

best-turned-based-rpgs-persona-3-reload-550x309.jpg best-turn-based-rpgs-sea-of-stars-550x309.jpg best-trpg-banner-saga-3.jpg

Obiective

Gestiune harta

Desi se poate gestiona o harta turn based si fara un sistem grid, cele mai adesea se folosesc sisteme grid de tip matrice sau hexagonal, ca si in imaginile exemplificate mai sus.

Daca in cazul unui sistem de tip matrice reprezentarea in memorie este simpla, folosind direct o matrice, in cazul unui sistem hexagonal, lucrurile sunt ceva mai complicate.

Cateva resurse suplimentare in acest sens:

Gestiune personaje

Pentru gestiunea personajelor sunt importante 3 elemente:

Intrucat sunt foarte multe elemente de gestionat, recomandarea este sa se incerce o decuplare a acestor componente, din punct de vedere logic.

Pentru selectia personajelor cel mai adesea se folosesc RayCast-uri. Pentru selectie multipla (mai multe personaje/unitati odata) o varianta poate fi:

Arhitectură generală

Factory Pattern

Permite generarea personajelor și abilităților din ScriptableObjects.

Rol: separă logica de creare a personajelor/abilităților de restul jocului.

Aplicație concretă:

Cod exemplu:

  [CreateAssetMenu(menuName = "Game/CharacterData")]
public class CharacterData : ScriptableObject {
    public string characterName;
    public int maxHP;
    public AbilityBase ability;
    public Sprite icon;
}
 
public class CharacterFactory {
    public Character Create(CharacterData data) {
        var character = new Character(data);
        return character;
    }
}

State Pattern

Rol: controlează clar ce se întâmplă în fiecare etapă: alegere, execuție, tranziție.

Controlează fluxul jocului între stările:

interface IGameState {
    void Enter();
    void Update();
    void Exit();
}
 
class PlayerTurnState : IGameState {
    public void Enter() => Debug.Log("Start player turn");
    public void Update() { /* input & comenzi */ }
    public void Exit() => Debug.Log("End player turn");
}

GameStateMachine gestionează schimbarea stărilor:

public class GameStateMachine {
    IGameState currentState;
    public void ChangeState(IGameState newState) {
        currentState?.Exit();
        currentState = newState;
        currentState.Enter();
    }
}

Command Pattern

Rol: fiecare acțiune a jucătorului (Move, Attack, Heal) e un Command care se stochează și se execută la finalul turei.

Encapsulează acțiuni:

  interface ICommand {
    void Execute();
}
 
class AttackCommand : ICommand {
    Character attacker, target;
    public AttackCommand(Character a, Character t) { attacker = a; target = t; }
    public void Execute() => target.TakeDamage(attacker.Attack);
}

Command design pattern permite acțiunilor să fie reprezentate ca obiecte. Încapsularea acțiunilor ca obiecte vă permite să creați un sistem flexibil și extensibil pentru controlul comportamentului GameObjects ca răspuns la intrarea utilizatorului. Aceasta funcționează prin încapsularea unuia sau mai multor apeluri de metodă ca „obiect de comandă”, mai degrabă decât invocarea directă a unei metode. Apoi puteți stoca aceste obiecte de comandă într-o colecție, cum ar fi o coadă sau o stivă, care funcționează ca un mic buffer.

Stocarea obiectelor de comandă în acest fel vă permite să controlați timpul de execuție a acestora prin eventual întârzierea unei serii de acțiuni pentru redarea ulterioară. În mod similar, puteți să le refaceți sau să le anulați și să adăugați flexibilitate suplimentară pentru a controla execuția fiecărui obiect de comandă.

* Jucătorul adaugă comenzi într-o listă.
* La finalul turei (EndTurn), toate comenzile sunt executate secvențial.
* Fiecare acțiune (mutare, atac, abilitate) este o Command:
interface ICommand {
    void Execute();
    void Undo(); // opțional
}

Exemple:

Fiecare obiect de comandă va fi responsabil pentru propriile sale metode Execute și Undo. Prin urmare, adăugarea mai multor comenzi în joc nu le va afecta pe cele existente.

Clasa CommandInvoker este apoi responsabilă pentru executarea și anularea comenzilor. În plus față de metodele ExecuteCommand și UndoCommand, are o stivă de anulare pentru a păstra secvența de obiecte de comandă.

O opțiune simplă pentru a schimba poziția jucătorului este crearea unui PlayerMover.

Pentru a face acest lucru, va trebui să treceți un Vector3 în metoda Mutare pentru a ghida jucătorul de-a lungul celor patru direcții ale busolei. De asemenea, puteți utiliza un raycast pentru a detecta pereții în LayerMask corespunzătoare. Desigur, implementarea a ceea ce doriți să aplicați command pattern este separată de modelul în sine.

Pentru a urma command pattern, capturați metoda de mutare a PlayerMover ca obiect. În loc să apelați direct Move, creați o nouă clasă, MoveCommand, care implementează interfața ICommand.

public class MoveCommand : ICommand
{
PlayerMover playerMover;
Vector3 movement;
public MoveCommand(PlayerMover player, Vector3 moveVector)
{
this.playerMover = player;
this.movement = moveVector;
}
public void Execute()
{
playerMover.Move(movement);
}
public void Undo()
{
playerMover.Move(-movement);
}
}

Indiferent de logică pe care doriți să o realizați, intră aici, așa că invocați Move cu vectorul de mișcare.

De asemenea, ICommand are nevoie de o metodă Undo pentru a restabili scena la starea anterioară. În acest caz, logica Undo scade vectorul de mișcare, împingând în esență jucătorul în direcția opusă.

MoveCommand stochează toți parametrii necesari pentru a-i executa. Setați-le cu un constructor. În acest caz, salvați componenta PlayerMover corespunzătoare și vectorul de mișcare.

Odată ce creați obiectul de comandă și salvați parametrii necesari acestuia, utilizați metodele statice ExecuteCommand și UndoCommand ale CommandInvoker pentru a transmite MoveCommand. Aceasta rulează Execute sau Undo din MoveCommand și urmărește obiectul de comandă din stiva de anulare.

InputManager nu apelează direct metoda PlayerMover's Move. În schimb, adăugați o metodă suplimentară, RunMoveCommand, pentru a crea o nouă MoveCommand și a o trimite către CommandInvoker.

Apoi, configurați diferitele evenimente onClick ale butoanelor UI pentru a apela RunPlayerCommand cu cei patru vectori de mișcare.

Mai multe detalii si exemple aici: https://unity.com/how-to/use-command-pattern-flexible-and-extensible-game-systems

Observer Pattern

Rol: decuplează subsistemele (UI, personaje, efecte).

Aplicație:

Folosit pentru a notifica UI-ul la schimbări:

  public static class EventBus {
    public static event Action<Character> OnCharacterDeath;
    public static void RaiseCharacterDeath(Character c) =>
        OnCharacterDeath?.Invoke(c);
}
  EventBus.OnCharacterDeath += UpdateUI;
 

În Character:

 
  public void TakeDamage(int dmg) {
    currentHP -= dmg;
    if (currentHP <= 0) EventBus.RaiseCharacterDeath(this);
}

In Unity, un motor de jocuri bazat pe componente, un aspect negresit pe care-l veti intalni pentru orice tip de joc sau aplicatie este comunicarea intre diversele componente ale acestuia.

Exista diverse modele de comunicare, in capitolul asta vom sublinia cateva ce tin de evenimente.

In esenta, un eveniment descrie o relatie de tip broadcaster-listener. In aceasta relatie avem, in general, un obiect care defineste evenimentul si-l invoca (face broadcast) si N alte obiect care asculta acest eveniment. Avantajul principal pentru utilizarea acestui mecanism este decuplarea sistemelor in cod - in special pe masura ce sistemele din joc devin mai numeroase si complexe.

Referinte directe

Cea mai simpla modalitate de comunicare este cea prin referinte directe. Scriptul A are o referinta la scriptul B - iar scriptul A apeleaza in mod direct metode ale scriptului B. In cazul acesta avem tight coupling intrucat A trebuie sa cunoasca detaliile lui B pentru a realiza comunicarea.

Acest model de comunicare nu este gresit (in esenta nici unul din cele prezentate nu e “cel mai bun”, ci depinde de context), dar se preteaza doar in cazul unor sisteme de dimensiune redusa, care se presupun ca nu vor suferi multe schimbari pe viitor.

Imaginati-va ca aveti scriptul de Player, precum si N alte sub-sisteme ale acestuia - PlayerUIManager, PlayerSoundManager, etc. - scriptul de Player va necesita o referinta catre fiecare dintre cele N subsisteme pentru a putea comunica. Vice-versa in cazul in care celelalte sisteme trebuie sa comunice cu Player-ul.

Un exemplu minimal este prezentat in urmatorul snippet:

public class Player : MonoBehaviour
{
   [SerializeField] private PlayerUIManager uiManager;
   [SerializeField] private PlayerSoundManager soundManager;
 
   private void UpdateSubsystems()
   {
       // Direct references - tight coupling.
       uiManager.UpdateUI();
       soundManager.PlaySomeSound();
   }
}

Evenimente C# (Action)

Acestea reprezinta un mecanism care ne ajuta in a decupla aceste componente. Aderam astfel intr-un mod mai activ la Single-responsibility principle - in acest mod, script-ul Player nu mai are nevoie de referinte catre subsistemele sale, ci doar implementeaza un eveniment la care va face broadcast.

In esenta, pe Player nu-l intereseaza cine asculta - acesta doar va emite acest mesaj si este responsabilitatea celorlalte subsisteme sa asculte acest eveniment si sa raspunda in mod corespunzator.

Totusi, in acest caz, subsistemele trebuie sa aiba o referinta catre player. Dar per total comunicarea este loosely coupled. Daca implementam cu grija si expunem via specificatorii de acces (public, protected, private) doar ce este de interes (in acest exemplu doar evenimentul), componenetele vor stii doar cat au nevoie din implementarea celorlalte componente.

Folosind acest sistem avem broadcaster-ul, care va emite / invoca evenimentul si ascultatorii, care trebuie sa se aboneze la acesta. Nu uitati sa va si dezabonati in mod explicit!

In snippet-ul urmator este prezentat un exemplu sumar:

public class Player : MonoBehaviour
{
   // This is the broadcaster. It does not care who else is listening to this.
   // Note that adding the `event` keyword enforces stricter rules for the `Action`:
   //  - This can only be invoked in the defined class (this one, `Player`)
   public event Action<int> OnPlayerHealthChanged;
 
   private void NotifyPlayerHealthChanged(int health)
   {
       OnPlayerHealthChanged?.Invoke(health);
   }
}
 
~~~~~
 
public class PlayerUIManager : MonoBehaviour
{
   // Reference to Player
   [SerializeField] private Player player;
 
   // Subscribe to event (listen).
   private void OnEnable() => player.OnPlayerHealthChanged += HandlePlayerHealthChanged;
 
   // Unsubscribe from the event (stop listening).
   private void OnDisable() => player.OnPlayerHealthChanged -= HandlePlayerHealthChanged;
 
   // Method which is called on event invocation
   private void HandlePlayerHealthChanged(int healthValue)
   {
       // Do something with `healthValue`  ...
   }
}

UnityEvents

Acesta este mecanismul specific Unity pentru implementarea comunicarii bazate pe evenimente, este in esenta un wrapper peste metodologia C# prezentata anterior. Avantajul folosirii acesteia este ca putem lega sisteme in cod intr-un mod si mai loosely coupled, intrucat ne putem baza pe adaugarea de referinte intre componenete direct in inspectorul din Unity, neavand neaparat nevoie de referinte in cod.

Tineti totusi minte ca C# events sunt in general mai performante, intrucat UnityEvents introduc un mic overhead - acest aspect devine relevant atunci cand aveti foarte multi broadcasteri si listeneri, asadar va recomandam sa folositi oricare in functie de contextul necesar.

public class Player : MonoBehaviour
{
   // This is the broadcaster. It does not care who else is listening to this.
   public UnityEvent<int> onPlayerHealthChanged;
 
   private void NotifyPlayerHealthChanged(int health)
   {
       onPlayerHealthChanged?.Invoke(health);
   }
}
 
~~~~~
 
public class PlayerUIManager : MonoBehaviour
{
   // No need to reference the Player anymore in code! But make sure to assign this in the inspector.
 
   // Method which is called on event invocation.
   public void HandlePlayerHealthChanged(int healthValue)
   {
       // Do something with `healthValue`  ...
   }
}

Callback-uri

Ne putem folosi de Action pentru a defeni callback-uri - mecanism util in cazul in care trebuie sa notificam “inapoi” un anumit script, fara a fi nevoiti sa adaugam o referinta in plus. Spre exemplu, script-ul A ruleaza ceva pe script-ul B, dar A vrea sa stie cand B a terminat. In loc sa adaugam o referinta de la B catre A, putem printr-un callback sa notificam A. Un exemplu este prezentat in continuare:

public class Player : MonoBehaviour
{
   [SerializeField] private OtherSystem otherSystem;
 
   public void DoSomething()
   {
       // () => { } is a lambda method - simply, a function within a function
       // which gets executed when the `callback` is invoked.
       otherSystem.DoSomeWork(() =>
       {
           // `otherSystem` just notified me!
       });
   }
}
 
~~~~~
 
public class OtherSystem : MonoBehaviour
{
   public void DoSomeWork(Action callback)
   {
       // ... doing some work.
 
       // Let's notify the calling script that I'm done!
       callback?.Invoke();
   }
}

Object Pooling

Rol: evită crearea/distrugerea frecventă a obiectelor (efecte de atac, particule, texte de damage).

Reutilizarea efectelor grafice și a obiectelor de particule.

  public class ObjectPool<T> where T : MonoBehaviour {
    private Queue<T> pool = new Queue<T>();
    public T Get(T prefab) {
        if (pool.Count == 0)
            return GameObject.Instantiate(prefab);
        return pool.Dequeue();
    }
    public void Return(T obj) {
        obj.gameObject.SetActive(false);
        pool.Enqueue(obj);
    }
}

Folosești un pool pentru particulele de heal, damage text etc.

MVC

Rol: structurează clar codul:

Structură exemplu:

/Scripts
    /Model
        CharacterData.asset
        AbilityData.asset
    /Controller
        CharacterController.cs
        TurnManager.cs
    /View
        CharacterView.cs
        HealthBarUI.cs

Avantaj:

Imaginati-va un joc in care aveti Player-ul si UI-ul acestuia sub forma unui prefab. Daca, spre exemplu, trebuie sa instantiati prefab-ul de UI dupa ce deja ati instantiat Player-ul, nu puteti, intr-un mod usor, sa foloseti metodele descrise anterior - cum iau referinta catre Player? Cum setez in inspector referinta de care am nevoie?

Un raspuns ar fi folosind FindObjectOfType, dar in general nu este o metoda recomandata, in primul rand datorita performance hit-ului (trebuie iterat prin toate obiectele scenei). In plus, imaginati-va un context multiplayer, in care aveti N game objects care au atasat script-ul Player - pe care-l referentiez?

O modalitate pentru a rezolva problema referentierii in scopul comunicarii intre obiecte si pentru a mentine conditia decuplarii intre acestea este sa folosesc un Scriptable Object ca un middleman intre diverse subsisteme.

Cum ar functiona pe scurt:

  • Gasiti aici documentatie legata de acest pattern
  • Gasiti aici un video ce intra in mai multe detalii legate de implementare

Astfel, decuplam aproape complet script-ul A de B via acest Scriptable Object. Putem acum sa atasam prefab-ului de Player cat si celui de UI ca referinta acest Scriptable Object si indiferent de momentul instantierii, cele doua sisteme vor putea comunica intr-un mod decuplat - nici unul nu trebuie sa stie de existenta celuilalt. Un exemplu minimal este prezentat in urmatorul snippet:

[CreateAssetMenu(
   fileName = "PlayerEventSO",
   menuName = "Player/PlayerEventSO")]
public class PlayerEventSO : ScriptableObject
{
   // Unity event.
   [NonSerialized] public UnityEvent<int> OnPlayerHealthChanged;
 
   // Event is initialized. `??=` is called the null-coalescing operator.
   // Scripts can listen to this.
   private void OnEnable() => OnPlayerHealthChanged ??= new UnityEvent<int>();
 
   // Method called from scripts which will raise / invoke `OnPlayerHealthChanged` event.
   public void NotifyPlayerHealthChanged(int playerHealth) => OnPlayerHealthChanged.Invoke(playerHealth);
}
 
~~~~~
 
public class Player : MonoBehaviour
{
   // Reference to Scriptable Object. Assigned in inspector.
   [SerializeField] private PlayerEventSO playerEventSO;
   public void UpdatePlayerHealth(int health)
   {
       // Call Scriptable Object method.
       playerEventSO.NotifyPlayerHealthChanged(health);
   }
}
 
~~~~~
 
public class PlayerUIManager : MonoBehaviour
{
   // Reference to Scriptable Object. Assigned in inspector.
   [SerializeField] private PlayerEventSO playerEventSO;
 
   // Subscribe to event (listen).
   private void OnEnable() => playerEventSO.OnPlayerHealthChanged.AddListener(HandlePlayerHealthChan
ged);
 
   // Unsubscribe from the event (stop listening).
   private void OnDisable() => playerEventSO.OnPlayerHealthChanged.RemoveListener(HandlePlayerHealthChanged)
 
   // Method which is called on event invocation
   private void HandlePlayerHealthChanged(int healthValue)
   {
       // Do something with `healthValue`  ...
   }
}

Event Bus

Prezentam un pattern pentru decuplare si mai “loosely coupled” decat cele anterioare (un fel de “final boss” 🙂), intrucat folosind aceasta metoda puteti comunica fara nicio referinta (nici macar indirecta, precum in cazul comunicarii via ScriptableObjects.

Reiteram faptul ca fiecare pattern are valoare intrinseca si ceea ce face unul sa fie mai “bun” decat altul depinde foarte mult de context.

  • Referentiarea directa este OK pentru prototipare radpida si proiecte mici
  • Event Bus-ul este recomandat pentru proiecte la scara larga

De asemenea, este important de mentionat faptul ca pe masura ce decuplam sisteme, debugging-ul si urmarirea flow-ului pot avea de suferit (ceea ce face codul este mai obfuscat).

Event Bus-ul reprezinta un sistem centralizat care face management de mesaje (ex. evenimente) si este un middle-man intre diversele componente ale unui proiect. Conceptul, este similar cu cel bazat pe ScriptableObjects prezentat anterior, dar nu mai necesita existenta unui obiect concret (ScriptableObject-ul) si poate defini tipuri de date arbitrare care sunt “pasate” intre componente.

La cel mai de baza nivel un event bus trebuie sa suporte

Asadar, o componenta A se va abona la un eveninment (asculta), iar componenta B va face broadcast/publish - moment in care A va executa codul aferent. In acest fel event bus-ul este un router de mesaje.

Decuplarea reise din faptul ca atat A si B trebuie sa fie “de acord” ce date transfera, dar cine si cum face comunicarea intre ele (event bus-ul) nu este de interes pentru niciuna din componentele aflate in schimbul de mesaje.

Inainte de a introduce un exemplu, clarificam pe scurt cateva elemente folosite in constructia event bus-ului.

Generics (T)

In C# puteti folosit tipuri de date generice prin type parameter-ul T, care este in esenta un placeholder pentru orice tip de date.

public class GenericList<T>
{
    private List<T> items = new List<T>();
 
    public void Add(T item) => items.Add(item);
}
 
public class Program
{
     public void Example()
     {
         var intList = new GenericList<int>();
         intList.Add(10);
 
         var stringList = new GenericList<string>();
         stringList.Add("PAJV");
     }
}

Records (C# 9+)

Un record este un tip special de clasa, folosita in principal pentru structuri de data imutabile (immutable data container). Se pot scrie/declara foarte concis. Implementeaza in mod automat

In esenta, cand este nevoie de un tip de date care poate fi referentiat si este imutabil, un record este o alternativa buna.

De exemplu:

public record PlayerHealthChangedEvent(int Current, int Max);

creeaza o clasa completa cu tot cu constructor, 2 proprietati (Current, Max), equality check.

Singleton

Event bus-ul propus este un Singleton (adeseori considerat de fapt un anti-pattern), anume astfel ne asiguram de faptul ca event bus-ul are o singura instanta si este accesibil in mod global. Detaliile de implmenetare ale acestuia sunt omise in laborator.

Exemplu implementare Event Bus

Event bus-ul:

public record OnPlayerDiedSoundEvent(AudioClip SoundToPlay);
public record PlayerHealthChangedEvent(int CurrentHealth, int MaxHealth);
// ... add more records as neeed ...
 
public class EventBus : Singleton<EventBus>
{
    private readonly Dictionary<Type, List<object>> _subscribers = new();
 
    // Abonare.
    public void Subscribe<T>(Action<T> listener) where T : class
    {
        var eventType = typeof(T);
 
        if (!_subscribers.ContainsKey(eventType))
        {
            _subscribers[eventType] = new List<object>();
        }
        _subscribers[eventType].Add(listener);
    }
 
    // Dezabonare.
    public void Unsubscribe<T>(Action<T> listener) where T : class
    {
        var eventType = typeof(T);
 
        if (_subscribers.TryGetValue(eventType, out var subscriber))
        {
            subscriber.Remove(listener);
        }
    }
 
    // Semnalare mesaj.
    public void Broadcast<T>(T eventData) where T : class
    {
        var eventType = typeof(T);
 
        if (_subscribers.TryGetValue(eventType, out var subscribers))
        {
            foreach (var subscriber in subscribers)
            {
                (subscriber as Action<T>)?.Invoke(eventData);
            }
        }
    }
}

Exemplu clasa Player care face Broadcast:

public class Player : MonoBehaviour
{
    // Omitted other implementation details...
 
    private void PlayerDamaged() => EventBus.Instance.Broadcast(new PlayerHealthChangedEvent(health, maxHealth));
 
    private void Die() => EventBus.Instance.Broadcast(new OnPlayerDiedSoundEvent(deathSound));
}

Exemplu de abonat:

public class PlayerUI : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI text;
 
    private void OnEnable() => EventBus.Instance.Subscribe<PlayerHealthChangedEvent>(OnPlayerHealthChanged);
 
    private void OnDisable() => EventBus.Instance.Unsubscribe<PlayerHealthChangedEvent>(OnPlayerHealthChanged);
 
    private void OnPlayerHealthChanged(PlayerHealthChangedEvent @event) => text.text = $"Health: {@event.CurrentHealth} / {@event.MaxHealth}";
}