Search Unity

Question Event System, callbacks, or any other solution?

Discussion in 'Scripting' started by Kousen10, Feb 12, 2024.

  1. Kousen10

    Kousen10

    Joined:
    Jul 23, 2020
    Posts:
    23
    Hello again guys,
    I have another question about my game design.

    Let's say I'm working on a turn-based game in which two teams face each other. In each "round", all the characters on the screen will take their turn, sorted by speed. I have designed a TurnManager class to control the turns and the question comes next:

    - Should I implement an EventSystem (which can be used for any other event present in the game) where it fires a TurnFinished event every time a character performs its action?
    - Should I follow a callback implementation, for example by adding a public Action to the Character class and referencing it in the TurnManager class: i.e. character.OnTurnFinshed += "Method of TurnManager class"?
    - Is there any other solution that I am not aware of?

    I would like to know your opinions or suggestions on how to approach this because it is being a headache since I started to think about it.

    Thank you very much in advance for your help!
     
    Last edited: Feb 12, 2024
  2. lordofduct

    lordofduct

    Joined:
    Oct 3, 2011
    Posts:
    8,532
    promised based system... something like async:
    https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/

    Instead of having a callback, the method returns a Task/ValueTask/UniTask/whatever-a-promise-task. Then you can await it to be finished and you know the turn has completed.

    Code (csharp):
    1. public interface ITurnHandler
    2. {
    3.     Task TakeTurn(object turndata);
    4. }
    5.  
    6. //just loop over your turn handlers on the turn in sequence awaiting each one taking their turn to simulate a "turn-based" system
    7. foreach (var eligible in eligibleTurnTakers)
    8. {
    9.     await eligible.TakeTurn(this);
    10. }
     
  3. Kousen10

    Kousen10

    Joined:
    Jul 23, 2020
    Posts:
    23
    I had not thought about asynchronous programming for this.... how interesting! The only thing I am not clear on is that, as in other turn-based games, the player characters will have to wait for the desired UI buttons to be pressed. My understanding of your solution is that I have to wait for an OnClick button event, but is this possible or does it make sense? I may have said this before, sorry for not being specific.
     
  4. lordofduct

    lordofduct

    Joined:
    Oct 3, 2011
    Posts:
    8,532
    I only suggest it cause you mentioned callbacks... and async promises can sometimes be a good replacement for callbacks.
     
    Kousen10 likes this.
  5. R1PFake

    R1PFake

    Joined:
    Aug 7, 2015
    Posts:
    542
    You can use TaskCompletionSource to mix UI events with async code:
    - Create a TaskCompletionSource and await it's task
    - Listen to the button click event, once the button was clicked, call the TaskCompletionSource SetResult method
    - The TaskCompletionSource task will be "completed" and the awaited code will continue
     
    Kousen10 likes this.
  6. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    7,923
    This might be a worthwhile watch if you want to go down the async route:


    A functional programming paradigm sounds like an interesting approach to a turn-based game. I imagine this might've been how older turn-based games operated.
     
    Kousen10 likes this.
  7. CodeRonnie

    CodeRonnie

    Joined:
    Oct 2, 2015
    Posts:
    531
    I haven't actually made a turn based game, outside of console apps when I was first learning to code. But, I imagine you could have a queue that handles initiative, turn order, and rounds. That ordered collection of turns and actions that get queued up by player or AI controller decisions during a turn keeps repeating until a win/loss condition occurs.

    Parts like HP, status, and defeat should work as usual. If a character dies they drop out of turn order. When one side is defeated, the other side wins. (You can come up with other fun win/loss conditions later.)

    Then, you just have to define what constitutes a turn for your game, how do you define and modify what a character can do, and how do the player and AI controllers interact with characters during their turn.

    Using events and callbacks in appropriate places is a good way to loosely couple, and easily link behavior. But, it won't solve everything. The key is to set up a state machine that moves through the turn order, that works and plays the way you want it to. Start with simple characters. Then you can begin customizing new characters, items, and actions.
     
    Last edited: Feb 13, 2024
    Kousen10 likes this.
  8. Kousen10

    Kousen10

    Joined:
    Jul 23, 2020
    Posts:
    23
    Thanks for all your comments. After reading them, they may work but I am not able to see how I can implement them in my game. So I will try to show a diagram of what I have just to see if I can explain my problem in a better way:
    Diagram.drawio.png
    In the picture above you can see some of the classes that I am using for my game. The question I have is how the TurnManager detects that the turn ends, since the execution of the BattleCommand is triggered from the UI part after pressing the desired UI buttons.
    Since I have events and an EventManager that triggers them, the most obvious way should be this...but the EventManager is in a Common assembly that needs the Characters assembly, so anything inside the Characters assembly won't be able to fire events.
    I thought about creating events in BattleCharacter like:
    public event Action<BattleCharacter> OnTurnFinished.
    And fill this event in the TurnManager during turn sorting but it's like I'm using two different event implementations and it seems wrong to me.
    Async could be another possibility but possibly due to my lack of programming knowledge I'm not able to see how to implement it since I'm sending an event from TurnManager to UIManager in the middle of the process.
    Having said all this... can you think of any other possibilities? I don't mind changing all my code if necessary as I want to learn how to do things in a way that makes sense.

    I hope I have made myself clear now and sorry for any previous misunderstandings.

    Thank you very much in advance guys!

    PS: The CharacterManager class registers a character when it spawns and unregisters it when it dies. This class is part of the Battle assembly so it is possible that it triggers events.
     
    Last edited: Feb 13, 2024
  9. CodeRonnie

    CodeRonnie

    Joined:
    Oct 2, 2015
    Posts:
    531
    I would suggest holding off on moving parts of this overall system into separate assemblies until you have the whole thing working, at least at a basic level. If the common assembly and the Characters assembly both need the EventManager, then it sounds like EventManager should be in its own assembly that each can reference. But, like I said, I wouldn't start breaking each module up into separate assemblies until you have the big picture working first. Even then, the question is always why are you separating the assemblies? What benefit are you getting? It's good to have an idea of the dependencies between classes in mind, but things change so often in prototyping that it seems too early to be compiling pieces of the whole into separate assemblies.
    Using standard C# events and delegates (and UnityEvents) is fine, even when you have your own EventManager system, but you're right that you should have some sort of standard about when and why you use one or the other. It shouldn't just be arbitrary which one you use, but using both in one code base is not inherently bizarre.

    The character instances at run time should have some sort of action economy that they deplete during their turn. Usually the character or AI will determine that they choose to end their turn, but if it's not possible to take further actions because you detect that there are no further resources for taking an additional action on that turn you could automatically end the turn depending on your design.
     
    Last edited: Feb 13, 2024
    Kousen10 likes this.
  10. R1PFake

    R1PFake

    Joined:
    Aug 7, 2015
    Posts:
    542
    I agree that you shouldn't worry about splitting assemblies at this point and make it easier for yourself.

    "The question I have is how the TurnManager detects that the turn ends, since the execution of the BattleCommand is triggered from the UI part after pressing the desired UI buttons."

    This is a personal preference, but for a turn based game I think differently about characters than in a real time or action game.

    In a real time action game I add a "brain" (for example state machine) to every character and they have their own logic to execute their own abilities and do their own stuff etc.

    In a turn based game I usually do it differently, there I have some kind of battle or turn class which has the logic and it uses the characters as "puppets".
    My turn class will know when a turn starts or ends and run all the code that should be executed in both cases, it will handle the ability selection (either tell the AI character to return a ability or show the UI for player characters), the UI will notify the turn class when a command is selected and then the turn class will run the logic of the command (by running the logic I mean, that I have some kind of interface for the commands and the turn class will use this interface to call some kind of start, update and cleanup methods, actual command logic is not implemented in the turn class itself).
    So it will also know when the command is done.
    The characters don't have their own "brain" they will only do what the turn manager tells them to do, like play animation, reduce some stat values etc.
     
  11. Nad_B

    Nad_B

    Joined:
    Aug 1, 2021
    Posts:
    730
    Personally I would use async paradigm (using the awesome UniTask library) like @lordofduct suggested, combined with a TurnSystem that finds and execute all processors you created, something like this:

    Code (CSharp):
    1. public interface ITurnProcessor
    2. {
    3.     int ExecutionOrder { get; }
    4.     UniTask<TurnProcessingResult> ProcessTurnAsync(TurnContext context);
    5. }
    6.  
    7. public enum TurnProcessingResult
    8. {
    9.     Success,
    10.     Failed
    11. }
    12.  
    13. // A data class shared between all processors, like a Blackboard,
    14. // where a processor can access, modify or set data for the next processors to use
    15. public TurnContext
    16. {
    17.     public Player Player { get; set; }
    18.     public List<UnitsDestinations> UnitDestinations { get; set; }
    19.     public AnyOtherNeededProperty { get; set; }
    20. }
    Some examples of TurnProcessors:
    Code (CSharp):
    1. public class MoveUnitsTurnProcessor : ITurnProcessor
    2. {
    3.     // ITurnProcessor implementation
    4.     public int ExecutionOrder => 0;
    5.  
    6.     // ITurnProcessor implementation
    7.     public async UniTask<TurnProcessorResult> ProcessTurnAsync(TurnContext context)
    8.     {
    9.         // Move each unit, one by one, to its destination.
    10.         // if we want to move them all at the same time,
    11.         // we use await UniTask.WhenAll() to run all tasks
    12.         // in parallel
    13.         foreach (var unitDestination in context.UnitsDestinations)
    14.         {
    15.             await MoveUnitAsync(unitDestination);
    16.         }
    17.  
    18.         return TurnProcessorResult.Success;
    19.     }
    20.  
    21.     private async UniTask MoveUnitAsync(UnitDestination unitDestination)
    22.     {
    23.         var unit = UnitDestination.Unit;
    24.         var unitTransform = unit.Transform;
    25.         var destination = UnitDestination.Destination;
    26.  
    27.         // Set unit destination (using NavMeshAgent for example...)
    28.         unit.SetDestination(destination);
    29.  
    30.         // awaits until the Unit has reached its destination
    31.         await UniTask.Until(() => Vector3.Distance(unitTransform.position, destination) < 0.05f);
    32.     }
    33. }
    Code (CSharp):
    1. // UI/Event based TurnProcessor
    2. public class WaitForUserSelectionTurnProcessor : MonoBehaviour, ITurnProcessor
    3. {
    4.     // Used to convert a normal event to Async...
    5.     private TaskCompletionSource<TurnProcessingResult> _clickTaskCompletionSource;
    6.     private TurnContext _turnContext;
    7.  
    8.     [SerializeFiled] private GameObject _selectionCanvas;
    9.     [SerializeField] private Button[] _buttons;
    10.  
    11.     private void Awake()
    12.     {
    13.         foreach (var button in _buttons)
    14.             button.Click += OnButtonClick;
    15.     }
    16.  
    17.     private void OnButtonClick()
    18.     {
    19.         // button clicked, do some actions...
    20.  
    21.          // Set the async TCS result...
    22.         _clickTaskCompletionSource.TrySetResult(TurnProcessingResult.Success);
    23.     }
    24.  
    25.  
    26.     // ITurnProcessor implementation
    27.     public int ExecutionOrder => 1;
    28.  
    29.     // ITurnProcessor implementation
    30.     public async UniTask<TurnProcessingResult> ProcessTurnAsync(TurnContext context)
    31.     {
    32.         // Show the buttons canvas...
    33.         _selectionCanvas.SetActive(true);
    34.  
    35.         // Create the TCS:
    36.         _clickTaskCompletionSource = new TaskCompletionSource<TurnProcessingResult>();
    37.  
    38.         // Set the current TurnContext, which can be used in the buttons click event
    39.         _turnContext = context;
    40.  
    41.         // Awaits and get the TCS result, set from the buttons click event callback
    42.         var result = await _clickTaskCompletionSource.Task;
    43.  
    44.         // Hide the buttons canvas:
    45.         _selectionCanvas.SetActive(false);
    46.  
    47.         // Return the result
    48.         return result;
    49.     }
    50.  
    51. }
    The TurnSystem:
    Code (CSharp):
    1. public class TurnSystem : Singleton<TurnSystem>
    2. {
    3.     private readonly IReadOnlyList<ITurnProcessor> _allTurnProcessors = GetAllTurnProcessors();
    4.  
    5.     public async UniTask<bool> ExecuteTurnAsync(TurnContext context)
    6.     {
    7.         foreach (var processor in _allTurnProcessors)
    8.         {
    9.             var result = await processor.ProcessTurnAsync(context);
    10.  
    11.             if (result != TurnProcessingResult.Success)
    12.             {
    13.                 // One of the processors failed, we just stop executing turns for example
    14.                 return false;
    15.             }
    16.  
    17.             return true;
    18.         }
    19.     }
    20.  
    21.     private static IReadOnlyList<ITurnProcessor> GetAllTurnProcessors
    22.     {
    23.         // We used reflection here. We could also make processors ScriptableObjects
    24.         // and assign them directly from the Editor, which will nicely solve
    25.         // the execution order problem too.
    26.         var result = new List<ITurnProcessor>();
    27.  
    28.         var processorsParent = new GameObject("Turn Processors (MonoBehaviours)");
    29.  
    30.         var interfaceType = typeof(ITurnProcessor);
    31.  
    32.         var allProcessorsTypes = interfaceType.Assembly.GetTypes()
    33.             .Where(x => !x.IsAbstract && interfaceType.IsAssignableFrom(x));
    34.  
    35.         foreach (var processorType in allProcessorsTypes)
    36.         {
    37.             // if it's a MonoBehaviour, we create a game object.
    38.             // We could also do a FindObjectOfType if the object is already in the scene.
    39.             if (typeof(MonoBehaviour).IsAssignableFrom(processorType))
    40.             {
    41.                 var go = new GameObject(processorType.Name);
    42.                 go.transform.SetParent(processorsParent.transform);
    43.                 result.Add((ITurnProcessor) go.AddComponent(processorType));
    44.             }
    45.             else
    46.             {
    47.                 result.Add((ITurnProcessor) Activator.CreateInstance(processorType));
    48.             }
    49.         }
    50.  
    51.         return result.OrderBy(x => x.ExecutionOrder).ToList();
    52.     }
    53. }
    Finally, in your "Execute Turn" button, you just do:
    Code (CSharp):
    1. private async void ButtonClick()
    2. {
    3.     // Fill your turn context...
    4.     var context = new TurnContext
    5.     {
    6.  
    7.     };
    8.  
    9.     var turnSuccessful = await TurnSystem.Instance.ExecuteTurnAsync(context);
    10. }
     
    Last edited: Feb 14, 2024
    Kousen10 likes this.