Conversations and Dialogue Triggers

OK so this may sound like a dumb question, considering the more advanced stuff that I have been asking over time, but my memory is kinda lost blacked out right now (considering that somehow, against all odds, I just got an entire NPC AI System to work marvelously, after (wait for it) a month of non-stop, continuous trying, and fixing a lot (and I mean A LOT) of weird corner cases, I am a little exhausted (I only have one single corner case left, but the risks outweigh the benefits, so I left it as is for the time being)). It all works based on event triggers (but ‘AggroGroup.cs’ also has a controlled ‘Update’ function, to help silence a significant bug), so everytime someone hits the player or someone from any team, their allies will automatically respond according to whether they’re free or not

There’s probably a lot of code cleaning up that needs to happen as well. Eventually I’ll post a video showcasing the gameplay for that

Anyway, what scripts do I need to add to any of my AI warriors (or regular NPCs) to allow conversation, and for them to join my player’s AggroGroup (and leave the group through dialogue as well)? I’ll create the functions for that, I just need the script names (I low-key forgot who goes where tbh, since I haven’t touched that system in a very long while)

And if we’re going down that path, can we also integrate audio to match conversation text, both in the editor and in the game itself? I managed to get my hands on an AI Voice tool, and I’d very much like to add audio to my in-game conversations down the line, and other minor advancements, but we’ll take it step by step (like not freezing the time during conversation, getting text to slowly show up as the conversation goes instead of all at once, etc)

For basic conversation each npc needs AIConversant.cs and a dialogue SO. Add the conditions as you write the dialogue.

thanks Ed. Will give it a try and keep you updated :slight_smile:

This is unity and you’ve posted as Unreal. Will update the tag for you

1 Like

woop, sorry. Probably did that by mistake :sweat_smile:

Thank you :smiley:

[STRAIGHT TO THE POINT REQUEST ON THE NEXT COMMENT, A FEW HOURS AFTER I REALIZED WHAT EXACTLY WENT WRONG. I STILL DIDN’T FIX IT, JUST UNDERSTOOD WHERE THE PROBLEM IS COMING FROM]

And… I have a new problem. @Brian_Trotter please do check it out sometime

I’m still in the early testing stages of this system, but I noticed that if my enemy dies and he respawns, the Dialogue Conversant system no longer works, and the system is telling me that something is missing in ‘RespawnManager.cs’ for that. Can you please have a look at my code and see how we can integrate that?

This is my current ‘RespawnManager.cs’ script:

using System.Collections.Generic;
using GameDevTV.Saving;
using RPG.Control;
using UnityEngine;
using RPG.Combat;
using Newtonsoft.Json.Linq;
using RPG.States.Enemies;
using System.Collections;
using System;
using RPG.Attributes;

namespace RPG.Respawnables
{
    [RequireComponent(typeof(JSONSaveableEntity))]
    public class RespawnManager : MonoBehaviour, IJsonSaveable
    {
        [SerializeField] EnemyStateMachine spawnableEnemy;

        [SerializeField] private float hideTime;   // time before hiding our dead character
        [SerializeField] private float respawnTime;    // time before respawning our hidden dead character, as another alive, respawned character
        [SerializeField] PatrolPath patrolPath; // the path our character will follow, from his spawn point
        [SerializeField] AggroGroup aggroGroup; // aggrevated group of guards, based on wrong dialogue player has said
        [SerializeField] bool hasBeenRestored;  // checks if the enemy has been restored before fading in from the main menu, or a load scene, or not

        // private AIController spawnedEnemy; // changed to EnemyStateMachine type below
        // private EnemyStateMachine spawnedEnemy; // in-game instance of the enemy

        // TEST (TimeKeeper)
        private double destroyedTime;
        private TimeKeeper timeKeeper;

        // --------------------------- NOTE: RestoreState() occurs BEFORE Start(), hence we need to change Start() to Awake() --------------
        private void Awake()
        {
            // TEST
            timeKeeper = TimeKeeper.GetTimeKeeper();

            // Check if the Enemy has been restored first or not, prior to Respawning him (ensuring RestoreState(), which occurs first, works properly)
            if (!hasBeenRestored) Respawn();
        }
        // --------------------------------------------------------------------------------------------------------------------------------

        private void Respawn()
        {
            var spawnedEnemy = GetSpawnedEnemy();

            if (spawnedEnemy != null)
            {
                // If you found an enemy as a child of this script holder, delete his 'OnDeath' listener
                spawnedEnemy.Health.onDie.RemoveListener(OnDeath);
            }

            foreach (Transform child in transform)
            {
                // Start the Respawn by deleting any existing gameObjects
                Destroy(child.gameObject);
            }

            spawnedEnemy = Instantiate(spawnableEnemy, transform);

            // If the enemy has a weapon that's supposed to be in his hand, make him wear it:
            if (spawnedEnemy.GetComponent<Fighter>().GetCurrentWeaponConfig() != null)
            {
                WeaponConfig enemyWeaponConfig = spawnedEnemy.GetComponent<Fighter>().currentWeaponConfig;
                spawnedEnemy.GetComponent<Fighter>().AttachWeapon(enemyWeaponConfig);
            }

            // Get the spawned/respawned enemies' health, and listen for death notifications
            spawnedEnemy.Health.onDie.AddListener(OnDeath);

            if (patrolPath != null)
            {
                Debug.Log($"Assigning Patrol Path {patrolPath} to {spawnedEnemy.name}");
                spawnedEnemy.AssignPatrolPath(patrolPath);
                // set the enemy's initial hostility here instead of 'EnemyStateMachine.Start()', otherwise the override will mess with your head for days 
                // (I made that mistake, hence why I'm writing about it here):
                // if the spawned Enemy is part of the Player's AggroGroup, turn off his hostility on respawn against the Player:
                if (spawnedEnemy != null && spawnedEnemy.GetAggroGroup() != null && spawnedEnemy.GetAggroGroup().GetPlayer()) spawnedEnemy.SetHostile(false);
                else spawnedEnemy.SetHostile(spawnedEnemy.GetInitialHostility);
                spawnedEnemy.SwitchState(new EnemyIdleState(spawnedEnemy));
            }
            else
            {
                Debug.Log($"No Patrol Path to assign");
            }

            // --------------------------- Extra Functionality: Setting up Aggro Group + Adding Fighters ---------------
            if (aggroGroup != null)
            {
                aggroGroup.AddFighterToGroup(spawnedEnemy);

                // -------------------------------- TEST AREA ------------------------------------------------------------------------------------------------------------------------

                // get the AggroGroup on Respawn, so when the enemy returns to life, he can go through the list of allies, and if any of them are under attack, he can try fight with them
                spawnedEnemy.SetAggroGroup(spawnedEnemy.GetAggroGroup());

                if (spawnedEnemy.GetAggroGroup() != null)
                {
                    foreach (EnemyStateMachine allyEnemy in spawnedEnemy.GetAggroGroup().GetGroupMembers())
                    {
                        if (spawnedEnemy != null && spawnedEnemy.GetAggroGroup().GetPlayer()) spawnedEnemy.SetHostile(false);
                        // a friend of the NPC that just respawned is under attack, so that just-respawned NPC should go and fight for them:
                        else if (allyEnemy != null && allyEnemy != spawnedEnemy && allyEnemy.GetOpponent() != null && allyEnemy.GetOpponent().GetComponent<EnemyStateMachine>().GetAggroGroup() != spawnedEnemy.GetAggroGroup())
                        {
                            // aim for whoever is attacking your allies, and then break after finding the first one:
                            spawnedEnemy.SetOpponent(allyEnemy.GetOpponent());
                            spawnedEnemy.SetHasOpponent(true);
                            // Find a way to set the hostility of respawned player allies here to false:
                            spawnedEnemy.SetHostile(true, allyEnemy.GetOpponent());
                            Debug.Log($"{spawnedEnemy.gameObject.name} Respawned and is supposed to fight... SetHostile set to {spawnedEnemy.IsHostile}");
                            break;
                        }
                    }
                }

                // -------------------------------------- END OF TEST AREA -----------------------------------------------------------------------------------------------------------

                if (spawnedEnemy.TryGetComponent(out DialogueAggro dialogueAggro)) //aggrogroup is at this point valid
                {
                    dialogueAggro.SetAggroGroup(aggroGroup);
                }
            }
            // ---------------------------------------------------------------------------------------------------------
        }

    private IEnumerator HideCharacter()
    {
        Debug.Log($"HideCharacter in progress");
        var spawnedEnemy = GetSpawnedEnemy();
        if (spawnedEnemy == null) yield break;
        spawnedEnemy.transform.SetParent(null);
        yield return new WaitForSecondsRealtime(hideTime);
        Destroy(spawnedEnemy.gameObject);
    }

    void OnDeath()
    {
        var spawnedEnemy = GetSpawnedEnemy();

        if (spawnedEnemy != null)
        {
            spawnedEnemy.Health.onDie.RemoveListener(OnDeath);            
            StartCoroutine(HideCharacter());
            destroyedTime = timeKeeper.GetGlobalTime();
            StartCoroutine(WaitAndRespawn());
            if (aggroGroup != null)
            {
                aggroGroup.RemoveFighterFromGroup(spawnedEnemy);
            }
        }
    }

    private IEnumerator WaitAndRespawn()
    {
        var elapsedTime = (float)(timeKeeper.GetGlobalTime() - destroyedTime);
        yield return new WaitForSecondsRealtime(respawnTime - elapsedTime);
        Respawn();
    }

    private bool IsDead()
    {
        var spawnedEnemy = GetSpawnedEnemy();
        return spawnedEnemy == null || spawnedEnemy.Health.IsDead();
    }

    private EnemyStateMachine GetSpawnedEnemy()
    {
        return GetComponentInChildren<EnemyStateMachine>();
    }

    public JToken CaptureAsJToken()
    {
            JObject state = new JObject();
            IDictionary<string, JToken> stateDict = state;

            // TEST (Adding data to the JObject Dictionary):
            var isDead = IsDead();
            var data = new RespawnData(destroyedTime, isDead);
            stateDict["RespawnData"] = JToken.FromObject(data);

            // we only care about data of alive enemies
            if (!isDead)
            {
                var spawnedEnemy = GetSpawnedEnemy();
                foreach (IJsonSaveable JSONSaveable in spawnedEnemy.GetComponents<IJsonSaveable>())
                {
                    JToken token = JSONSaveable.CaptureAsJToken();
                    string component = JSONSaveable.GetType().ToString();
                    Debug.Log($"{name} Capture {component} = {token.ToString()}");
                    stateDict[component] = token;
                }
            }
            return state;
    }

        public void RestoreFromJToken(JToken s)
        {
            
            JObject state = s.ToObject<JObject>();
            IDictionary<string, JToken> stateDict = state;

            var data = default(RespawnData);
            if (stateDict.TryGetValue("RespawnData", out var dataToken))
            {
                data = dataToken.ToObject<RespawnData>();
            }

            var isDead = data.IsDead;
            destroyedTime = data.DestroyedTime;

            // Should be dead
            if (isDead && !IsDead())
            {
                Debug.Log("Should be dead, but isn't...");
                var spawnedEnemy = GetSpawnedEnemy();
                Debug.Log($"Listeners before: {spawnedEnemy.Health.onDie.GetPersistentEventCount()}");
                spawnedEnemy.Health.onDie.RemoveListener(OnDeath);
                Debug.Log($"Listeners after: {spawnedEnemy.Health.onDie.GetPersistentEventCount()}");
                Debug.Log($"Health Before: {spawnedEnemy.Health.GetHealthPoints()}");
                spawnedEnemy.Health.Kill();
                Debug.Log($"Health After: {spawnedEnemy.Health.GetHealthPoints()}");
                StartCoroutine(WaitAndRespawn());
                if (aggroGroup != null)
                {
                    aggroGroup.RemoveFighterFromGroup(spawnedEnemy);
                }
                StartCoroutine(HideCharacter());
                // HideCharacter();
                Debug.Log($"Spawned Enemy: {GetSpawnedEnemy()}");
            }
            else if (isDead && IsDead())
            {
                Debug.Log("Should be dead, and is indeed dead...");
                StopAllCoroutines();
                StartCoroutine(WaitAndRespawn());
                StartCoroutine(HideCharacter());
                // HideCharacter();
            }
            // Should be alive
            else if (!isDead && IsDead())
            {
                Debug.Log("Shouldn't be dead, but is dead...");
                Respawn();
                LoadEnemyState(stateDict);
            }
            else
            {
                Debug.Log("Shouldn't be dead, and isn't dead...");
                LoadEnemyState(stateDict);
            }
        }

        private void LoadEnemyState(IDictionary<string, JToken> stateDict)
        {
            var spawnedEnemy = GetSpawnedEnemy();
            foreach (IJsonSaveable jsonSaveable in spawnedEnemy.GetComponents<IJsonSaveable>())
            {
                string component = jsonSaveable.GetType().ToString();
                if (stateDict.ContainsKey(component))
                {
                    Debug.Log($"{name} Restore {component} => {stateDict[component].ToString()}");
                    jsonSaveable.RestoreFromJToken(stateDict[component]);
                }
            }
        }
    }

    [Serializable]
    public struct RespawnData
    {
        public bool IsDead;
        public double DestroyedTime;

        public RespawnData(double destroyedTime, bool isDead)
        {
            IsDead = isDead;
            DestroyedTime = destroyedTime;
        }
    }
}

And these are the errors I get:

MissingReferenceException: The object of type 'AIConversant' has been destroyed but you are still trying to access it.
Your script should either check if it is null or you should not destroy the object.
RPG.Core.RangeFinder`1[T].FindNearestTarget () (at Assets/Project Backup/Scripts/Core/RangeFinder.cs:55)
RPG.States.Player.PlayerFreeLookState.InputReader_HandleDialogueEvent () (at Assets/Project Backup/Scripts/State Machines/Player/PlayerFreeLookState.cs:138)
RPG.InputReading.InputReader.OnDialogue (UnityEngine.InputSystem.InputAction+CallbackContext context) (at Assets/Project Backup/Scripts/Input Controls/InputReader.cs:175)
UnityEngine.InputSystem.Utilities.DelegateHelpers.InvokeCallbacksSafe[TValue] (UnityEngine.InputSystem.Utilities.CallbackArray`1[System.Action`1[TValue]]& callbacks, TValue argument, System.String callbackName, System.Object context) (at Library/PackageCache/com.unity.inputsystem@1.5.1/InputSystem/Utilities/DelegateHelpers.cs:46)
UnityEngine.InputSystem.LowLevel.<>c__DisplayClass7_0:<set_onUpdate>b__0(NativeInputUpdateType, NativeInputEventBuffer*)
UnityEngineInternal.Input.NativeInputSystem:NotifyUpdate(NativeInputUpdateType, IntPtr)

and this one:

MissingReferenceException while executing 'performed' callbacks of 'UI/Dialogue[/Keyboard/r]'
UnityEngine.InputSystem.LowLevel.NativeInputRuntime/<>c__DisplayClass7_0:<set_onUpdate>b__0 (UnityEngineInternal.Input.NativeInputUpdateType,UnityEngineInternal.Input.NativeInputEventBuffer*)
UnityEngineInternal.Input.NativeInputSystem:NotifyUpdate (UnityEngineInternal.Input.NativeInputUpdateType,intptr)

‘R’ is the temporary button I’m using to interact with dialogues with NPCs

Can you please help me fix it?

I shared the “RespawnManager.cs” because the problem does not exist when the game starts, but it becomes severe when the character respawns, so I assumed that’s where it’s coming from

Another problem I have is that I currently have the ability to talk to Conversants when they’re dead. That’s… kinda NOT what I want :sweat_smile: - how do we eliminate that? (I added a check against that in ‘AIConversant.IsValid’, but it didn’t seem to work, as follows):

using RPG.Control;
using UnityEngine;
using RPG.Attributes;
using RPG.Core;

namespace RPG.Dialogue {

    public class AIConversant : MonoBehaviour, ITarget //, IRaycastable
    {

        [SerializeField] Dialogue dialogue = null;
        [SerializeField] string conversantName;
        // [SerializeField] PlayerController player;
        // [SerializeField] Health thisCharacterHealth;

        /* public CursorType GetCursorType()
        {
            return CursorType.Dialogue;
        } */

        /* public bool HandleRaycast(PlayerController callingController)
        {

            if (dialogue == null) {

                return false;

            }

            if (thisCharacterHealth && thisCharacterHealth.IsDead()) { return false; }  // if the NPC is alive and has health, ignore raycast (i.e: show the dialogue Cursor)
            
            if (Input.GetMouseButtonDown(0))
            {
                transform.LookAt(callingController.transform);  // This line is solely responsible for ensuring our 'Dialogue-Triggered' guard can be a 'Respawn Manager' child, making him able to engage in conversation, even if he is respawnable
                callingController.GetComponent<PlayerConversant>().StartConversation(this, dialogue);                
            }

            return true;

        } */

        public string GetName() {

            return conversantName;

        }

        // Third Person Transition Implementation:
        public Dialogue GetDialogue() 
        {
            return dialogue;
        }

        public bool IsValid() 
        {
            if (dialogue == null) return false; // if there is no dialogue, NPC is not open for Talks
            if (TryGetComponent(out Health health) && health.IsDead()) return false;    // you can't talk to Dead men... LMAO
            return true;
        }

    }

}

What’s really driving me nuts, is that it won’t catch it although the respawned enemy DOES have an AIConversant.cs script on him…

Sometimes it works, MOST OF THE TIME IT DOESN’T, And now I’m insanely confused as to what’s going on

Edit 1: Apart from the fact that this system is a complete fail when the game is built, I noticed something:

If you want to talk to enemies on death, sometimes it will, and that’s the dead giveaway that it’s about to fail

And if you keep hitting the ‘R’ (Dialogue key) many times after he’s gone before respawning, umm… let’s just say you’ll get the ‘Has been destroyed, but you’re still trying to access it’ line pretty much always on that build, and that’s not what I want…

Please help :sweat_smile: (honestly I think it would be lovely if we can both kill and engage in conversations with enemies lol… that would really make for an incredibly fun gameplay, but this problem is really really REALLY baffling me)

Edit 2: Why don’t we call ‘RemoveTarget’ when the enemy dies and just call it a day? I’m not sure how to do that yet, just a thought to think of

(The big problem I have is, is that when the enemy is dead, the target is still available to be interacted with, which kinda breaks something. If we can block the player from being able to use the conversation button to interact with the dead NPC that will solve EVERYTHING. Don’t ask me how, but I think ‘IsValid()’ missed it, although the condition is there).

What if we put the ‘IsValid’ in an Update function? Because I don’t think just a trigger of ‘OnTriggerEnter’ will do the trick for when the enemy is dead tbh

OK so… to put everything in perspective and avoid confusion, I just noticed what the root of the problem is (for why my player can talk to dead bodies):

Essentially, the entire range finder runs on EVENTS, which only get updated when the player gets and leaves the target’s field, and when the conversant dies and the player is still in his range, the event does NOT GET UPDATED when the player tries to talk to the dead enemy again, which means the player can talk to dead bodies as long as he’s close to them, and essentially giving us a permanent error every time we ever try to talk to someone again after that point in time

The only solution I can think of, is to use the Update function (for a single frame) to delete whatever target we had that just got killed, otherwise that error will never go away, and it’ll be a significant problem down the line

My big problem now is, what do I write in an Update function to overcome this problem. More importantly, where does it go…?!

Edit 1: I solved it. This goes into ‘ConversantFinder.cs’:

        private void Update()
        {
            for (int i = Targets.Count - 1; i >= 0; i--)
            {
                if (!Targets[i].IsValid())
                {
                    // from 'AIConversant.cs', we only get here if 
                    // either the dialogue is gone, or the AIConversant NPC
                    // is dead, and we are in their range.
                    // This prevents the issue of being able to talk to dead NPCs,
                    // which leads to more serious issues
                    RemoveTarget(Targets[i]);
                }
            }
        }

But please confirm it for me, because I’m not sure what sort of issues will running a for loop have on performance on the long run, especially as the list grows longer. Would using a boolean flag make it any better? If so, how do I do it? (I don’t think confirming matters much though, since I’m most likely going to roll with about 5 maximum allies at any point in time)

Not gonna be much help on that… Do know that some stores (like the Steam Store) will not allow you to publish your game if you use AI Generative tools. That may change (or may have, not sure).

Have a think about what we did about this problem in the Targeter…

This would mean adding an OnDeath to the AIConversant, and adding a method that subscribes to the Health component (if it can find one) attached to the AIConversant. That method would then invoke OnDeath?.Invoke(this);

Then in ConversantFinder, you’ll override the AddTarget and RemoveTarget, using code just like in Target.

I did a little bit of research, it says that the use of AI Content has to be disclosed, and no illegal stuff used. I don’t think the ‘DeepVoice’ Asset on the Unity Asset Store should have any illegal voice use issues… Just let it be known there’s AI Voice, and don’t somehow closely mimic anyone’s voice, and we should be just fine (eventually I’ll want to use real voices instead, but I can’t afford that right now)

I completely forgot that this thing even existed :sweat_smile:

If I knew how to write and use events properly (I’m really struggling with that part), I would’ve written a much much better code for my AI Fighting System than what I did, which unfortunately has a few funky bugs that just came along the way because of poor code handling from my side (I might send this over for review down the line, but be prepared because I’m sure it looks straight-up awful)

Anyway, I’ll go see what happened over there for the target and targeter, and try fix it

1 Like

OK so… it worked. Thank you. Now I can start exploring solutions to get AI NPCs to join my team through dialogue, but this will be a little tricky, because my entire game now relies on having enemies that have empty game parents with ‘AggroGroup.cs’ scripts attached to them…

BUT… I have a small question:

        // Solution to use events to deal with the death of enemies.
        // and block the ability of talking to dead enemies, below:
        public event System.Action<AIConversant> OnDeath;

        private Health health;

        private Health Health 
        {
            get 
            {
                // if you can find health attached to this conversant
                // get it (so we can subscribe to it in 'Awake()'):
                if (!health) health = GetComponent<Health>();
                return health;
            }
        }

        private void Awake() 
        {
            Health.onDie.AddListener(() => 
            {
                OnDeath?.Invoke(this);
            });
        }

Why are we using a complex-looking getter? Why don’t we just use a ‘return GetComponent < Health >();’ straight away? (And why do we have two ‘health’ components (As in, what’s the point of the small h Health)?
And if anyone else needs this down the line, here’s the updated ‘ConversantFinder.AddTarget’ and ‘ConversantFinder.RemoveTarget’:

        protected override void AddTarget(AIConversant target)
        {
            base.AddTarget(target);
            target.OnDeath += RemoveTarget;
            Debug.Log($"ConversantFinder: Adding {target.GetName()}");
        }

        protected override void RemoveTarget(AIConversant target)
        {
            base.RemoveTarget(target);
            target.OnDeath -= RemoveTarget;
            Debug.Log($"ConversantFinder: Removing {target.GetName()}");
        }

Actually, I put it in there to demonstrate a self defining property, that is extremely resistant to race conditions. You can always do it the old fashioned way.

In fact, if there’s any chance that you won’t have a Health on a Conversant (just an ordinary NPC nobody can attack, for example), then you need to also null check Health in Awake() before subscribing to Health.OnDie.

I’m keeping it then :stuck_out_tongue: - that’s more than enough reason for me to keep it. Can we do it a little more down the line to help me further be resistant to errors? That would be amazing (albeit a little confusing at first, but that’s perfectly fine for me)

Ehh I’m probably going to have all my NPCs with health down the line. I want to have a fun factor of being able to blow up innocent NPCs for no reason, just to keep the game open and fun (the freedom of an open world is quite important to me if I’m being honest with you)

If you kill someone, you might permanently lose your ability to do a quest and affect your gameplay path. That’ll make players think twice before killing someone (I don’t know why, but people are addicted to that kinda stuff)

It’s about as bullet proof as you can get. If the component doesn’t exist at all, then it’s no better than caching it in Awake. Coupled with a [RequireComponent(typeof(Health))] before the class Declaration would guarantee a Health component, that’s about it.

1 Like

[SKIP TO THE NEXT COMMENT TO SEE MY PROGRESS]

Alright so… now comes the complex part of actually switching teams for an in-game NPC, from his own Individual self-generated AggroGroup (My game’s hierarchy relies on that now, it needs one otherwise NREs will start jumping out of nowhere. I did that on purpose because if a town has multiple people for example, they can all aim for the same guy for example… it’ll be kinda funny not knowing who you’re up against, xD), with a Respawn Manager underneath it, followed by the player and his components, to wanting to place the enemy within the ‘PlayerAllyGroup’ gameObject on my game (I don’t mind the naming), and adding him to the list of allies of the Player’s AggroGroup, typically through either a ‘GameObject.Find()’ search for the name, or getting the only AggroGroup in the game with a player component on it

When he enters the Player’s AggroGroup, I’m guessing his individual AggroGroup will have to be destroyed, and when he leaves the group, through dialogue as well, he has to instantiate his own AggroGroup, otherwise errors will show up

From my understanding of the Quest Giver, I’ll need a script that holds this switch on the enemy himself as well, otherwise the dialogue editor won’t catch it

I’m still thinking this through, but what would be the best way to approach this problem?

If it helps, this is my ‘AggroGroup.cs’ script:

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using Unity.VisualScripting;
using RPG.Attributes;
using RPG.States.Enemies;
using RPG.States.Player;

namespace RPG.Combat
{

    public class AggroGroup : MonoBehaviour
    {
        [Header("The Player's Individual AggroGroup MUST BE A PARENT OF THE PLAYER,\notherwise the first if statement in the first Foreach loop\nin the first if statement in 'OnTakenHit' will act up!")]

        [Header("This script aggrevates a group of enemies,\n if you attack one of their friends.\n The player must be in their 'PlayerChasingRange' though\n otherwise they won't try attacking you")]

        [Tooltip("The list of enemies this AggroGroup has")]
        [SerializeField] List<EnemyStateMachine> enemies = new List<EnemyStateMachine>();

        [Tooltip("Set this to true only for groups of enemies that you want naturally aggressive towards the player")]
        [SerializeField] bool groupHatesPlayer = false;

        [Tooltip("Set to true only if you want enemies to forget any problems they had with the player when he dies")]
        [SerializeField] bool resetIfPlayerDies = false; // this is left as a boolean because it allows us to choose which aggroGroup members reset their settings on players' death, and which groups may not reset themselves on death (depending on your games' difficulty)

        [Header("IF THE PLAYER IS NOT SUPPOSED TO BE NULL FOR THIS GROUP, THEN\nKEEP THIS AS A PARENT OF THE PLAYER, OTHERWISE\nIT WILL MESS EVERYTHING UP!")]
        [Tooltip("Is the player a part of this aggroGroup? If so, it will be placed here through code")]
        [SerializeField] PlayerStateMachine player;

        private Health playerHealth;
        private AggroGroup playerAggroGroup;

        void Awake() 
        {
            // Getting the player to be in his individual AggroGroup, which is the group parenting the player:
            if (this.GetComponentInChildren<PlayerStateMachine>()) player = GameObject.FindWithTag("Player").GetComponent<PlayerStateMachine>();
            // Getting the Player's AggroGroup:
            playerAggroGroup = GameObject.FindWithTag("Player").GetComponentInParent<AggroGroup>();
        }

        /// <summary>
        /// At the start of the game
        /// </summary>
        private void Start()
        {
            // If the entire group hates the player, they're all set to hostile, 
            // otherwise everyone is at their initial hostility:
            if (groupHatesPlayer) Activate(true);
            else foreach (EnemyStateMachine enemy in enemies) // this can't go to 'EnemyStateMachine.cs'. Doing so will conflict with 'RespawnManager.cs''s hostility. It stays here
            {
                // if the enemy is part of the same AggroGroup as the player, deactivate Hostility (and do that when Respawned as well)
                // otherwise set Hostility to default hostility setting, if that enemy is not in the same AggroGroup as the player
                if (enemy.GetAggroGroup().GetPlayer() != null) enemy.SetHostile(false);
                else enemy.SetHostile(enemy.GetInitialHostility);
            }
        }

        public void OnEnable()
        {
            if (resetIfPlayerDies)
            {
                playerHealth = GameObject.FindGameObjectWithTag("Player").GetComponent<Health>();
                if (playerHealth != null) playerHealth.onDie.AddListener(ResetGroupMembers);
            }
        }

        public void OnDisable()
        {
            playerHealth.onDie.RemoveListener(ResetGroupMembers);
        }

        /// <summary>
        /// Is this enemy within the same AggroGroup as the player?
        /// </summary>
        /// <returns>Player GameObject</returns>
        public PlayerStateMachine GetPlayer() 
        {
            return player;
        }

        /// <summary>
        /// Called when the player dies, and everyone is reset to their original state (resetIfPlayerDies must be set to true)
        /// </summary>
        public void ResetGroupMembers()
        {
            if (groupHatesPlayer) Activate(true);

            else if (playerHealth != null) 
            {
            foreach (EnemyStateMachine enemy in enemies)
            {
                enemy.SetHostile(enemy.GetInitialHostility);
            }
            }
        }

        /// <summary>
        /// Used when you're sure the WHOLE GROUP either likes you, or hates you (otherwise use 'EnemyStateMachine.SetHostile' for individuals)
        /// </summary>
        /// <param name="shouldActivate"></param>
        public void Activate(bool shouldActivate)
        {
            enemies.RemoveAll(enemy => enemy == null || enemy.IsDestroyed());

            foreach (EnemyStateMachine enemy in enemies)
            {
                enemy.SetHostile(shouldActivate);
            }
        }

        public void AddFighterToGroup(EnemyStateMachine enemy)
        {
            // If you got the fighter you want to add on your list, return:
            if (enemies.Contains(enemy)) return;
            // For other fighters on the list, add them:
            enemy.SetAggroGroup(this);
            enemies.Add(enemy);
            enemy.GetComponent<Health>().OnTakenHit += OnTakenHit;
        }

        public void RemoveFighterFromGroup(EnemyStateMachine enemy)
        {
            // if the enemy is gone, don't try removing him again:
            if (enemy == null) return;
            // else Remove fighters from the list
            enemy.SetAggroGroup(null);
            enemies.Remove(enemy);
            enemy.GetComponent<Health>().OnTakenHit -= OnTakenHit;
        }

        public bool HasMember(EnemyStateMachine enemy) 
        {
            return enemies.Contains(enemy);
        }

        public List<EnemyStateMachine> GetGroupMembers() 
        {
            if (enemies == null) return null;
            else return enemies;
        }

        private bool isDead;

        public bool GetIsDead()
        {
            return isDead;
        }

        public void SetIsDead(bool isDead)
        {
            this.isDead = isDead;
        }

        /// <summary>
        /// This Update function forces the enemy to delete any 'Missing'
        /// Opponent gameObjects they might have, ensuring that they return
        /// to their normal action if an enemy went missing, or is dead (for those stupid walking corpses,
        /// I need to deal with that later...)
        /// </summary>
        void Update()
        {
            // 'IsDead' is a boolean flag. The reason it's here is to ensure this function is called
            // ONCE, AND ONCE ONLY, when an enemy is dead, to get everyone to forget about him. If you don't
            // do that, it'll call repetitively and cause some performance issues:

            // IsDead is called from 'EnemyStateMachine.OnDie()', when the enemy dies, and it's turned back off
            // here after the data of the dead enemies' hunters is wiped out:
            if (isDead)
            {
                foreach (EnemyStateMachine enemy in FindObjectsOfType<EnemyStateMachine>())
                {                                   // just to make sure we don't accidentally stalk ghost enemies...
                    if (enemy.GetOpponent() != null && enemy.GetOpponent().GetComponent<Health>().IsDead())
                    {
                        // if you're in the Player's AggroGroup, you have special treatment for hostility,
                        // as you can't be hostile against the player, who is your Ally
                        if (enemy.GetAggroGroup() != null && enemy.GetAggroGroup().GetPlayer())
                        {
                            Debug.Log($"{enemy.gameObject.name} is getting hostility set to false");
                            enemy.SetHostile(false, null);
                            enemy.SetOpponent(null);
                            enemy.SetHasOpponent(false);
                            // enemy.SwitchState(new EnemyIdleState(enemy)); // TEST - 13/5/2024
                            isDead = false;
                        }
                        // if you're not in the Player's AggroGroup, just be normal
                        else
                        {
                            Debug.Log($"{enemy.gameObject.name} is getting hostility set to initial hostility");
                            enemy.SetHostile(enemy.GetInitialHostility);
                            enemy.SetOpponent(null);
                            enemy.SetHasOpponent(false);
                            // enemy.SwitchState(new EnemyIdleState(enemy)); // TEST - 13/5/2024
                            isDead = false;
                        }
                    }
                }
                return;
            }
        }

        // -------------------------------- Want Superior AI Intelligence? THIS IS IT! ----------------- 
        // The Final Version of 'OnTakenHit()'. This version does what the third version did, but can also deal
        // with cases where the player is part of an AggroGroup, and needs to call his allies when he's under attack ---------------------------

        /// <summary>
        /// This is the smartest version of 'OnTakenHit'. Essentially, it initially checks for if the Player was the one 
        /// who instigated the damage. If true, the player's allies will aim for the nearest enemies of the player's
        /// Opponent's AggroGroup team they can find.
        /// If the player is solo, all enemies in the enemy's 'PlayerChaseRadius' radius will attempt to attack 
        /// the Player. If the instigator is an enemy, get the nearest unassigned enemy you can find 
        /// in the Enemy's AggroGroup, and aim for that. If you can't find any, 
        /// get the closest enemy you can find
        /// </summary>
        /// <param name="instigator"></param>
        void OnTakenHit(GameObject instigator)
        {
            // Check if the instigator is the player, or anyone in the player's team:
            if (instigator.GetComponent<PlayerStateMachine>())
            {
                // If the instigator is in player chasing range, then all enemies in this victim's AggroGroup, within Player Chasing Range, with no opponent, shall aim for the player (regardless of Hostility):
                foreach (EnemyStateMachine enemy in enemies.Where(enemy => enemy != null && enemy.GetAggroGroup() == this))
                {
                    Debug.Log("We have entered the foreach loop");
                    // if the victim of the player is not part of his AggroGroup (i.e: "GetPlayer() == null"),
                    // enemies in the players' AggroGroup shall aim
                    // for the victim's team members, and this shall turn into a big fight:
                    if (GetPlayer() == null)
                    {
                        Debug.Log($"PlayerAggroGroup: {playerAggroGroup.gameObject.name}");

                        // Player hit someone from outside:
                        if (playerAggroGroup != null && playerAggroGroup != this)
                        {
                            Debug.Log("Not part of the Player's AggroGroup");
                            foreach (EnemyStateMachine playerAllyEnemy in playerAggroGroup.GetGroupMembers().Where(playerAllyEnemy => playerAllyEnemy != null))
                            {
                                Debug.Log("PlayerAggroGroup members accessed");
                                // All available enemies in PlayerAggroGroup should get ready to fight:
                                if (playerAllyEnemy != null && playerAllyEnemy.GetOpponent() == null && !playerAllyEnemy.GetHasOpponent())
                                {
                                    EnemyStateMachine closestEnemy = GetClosestEnemyFromPlayerOpponentAggroGroup(playerAllyEnemy);
                                    Debug.Log("Closest Enemy for the Player's Ally Enemy has been accumulated");

                                    // FIGHT (turn on enemy-checking here, so that enemies who are unavailable are eliminated):
                                    if (closestEnemy != null /* && closestEnemy.GetOpponent() == null */)
                                    {
                                        // Player's ally targets the closest enemy in the Victim's AggroGroup:
                                        playerAllyEnemy.SetHasOpponent(true);
                                        playerAllyEnemy.SetOpponent(closestEnemy.gameObject);
                                        playerAllyEnemy.SetHostile(true, closestEnemy.gameObject);

                                        // the Closest Enemy in the Victim's AggroGroup should aim for the Player's ally as well:
                                        closestEnemy.SetHasOpponent(true);
                                        closestEnemy.SetOpponent(playerAllyEnemy.gameObject);
                                        closestEnemy.SetHostile(true, playerAllyEnemy.gameObject);
                                    }

                                    Debug.Log($"{playerAllyEnemy.gameObject.name} is now hostile towards {enemy.gameObject.name}, as they are not part of our AggroGroup");
                                    Debug.Log($"{enemy.gameObject.name} is now hostile towards {playerAllyEnemy.gameObject.name}, as they are not part of the AggroGroup");
                                }
                            }
                        }
                        else {Debug.Log($"Player's AggroGroup either not found, or is the same as this one");}
                    }

                    // For cases where I have no opponent, and the player within my chase range has hit my ally, I shall aim for the player
                    // (and surprisingly, it works beautifully for both the player allies and enemies, so we can essentially
                    // get player allies to flip on him if he tries to flip on one of them, and they're not fighting anyone else
                    // (kind of like a 'penalty for trying to betray the team' system)):
                    if (enemy.GetOpponent() == null && !enemy.GetHasOpponent() && enemy.IsInPlayerChasingRange())
                    {
                        // 'EnemyStateMachine.ClearPlayer()' takes care of individually clearing up enemies when the player gets out of Chase Range
                        // and is called in 'EnemyChasingState.cs'
                        enemy.SetHostile(true, instigator.gameObject);
                        enemy.SetOpponent(instigator.gameObject);
                        enemy.SetHasOpponent(true);

                        Debug.Log($"{enemy.gameObject.name} is now hostile towards the player In Chase Range.");
                    }
                }
                return;
            }

            // BELOW IS THE CASE FOR WHEN THE INSTIGATOR WAS ANOTHER NPC, NOT THE PLAYER:

            // Get the (instigator) attacker's aggro group, who is NOT the player:
            AggroGroup attackerAggroGroup = instigator.GetComponent<EnemyStateMachine>()?.GetAggroGroup();

            foreach (EnemyStateMachine enemy in this.enemies)
            {
                // Find the nearest enemy from the attacker's group
                EnemyStateMachine nearestEnemy = null;
                if (attackerAggroGroup != null && attackerAggroGroup != this)
                {
                    nearestEnemy = GetNearestUnassignedEnemy(enemy, attackerAggroGroup.GetGroupMembers());
                    Debug.Log($"nearestEnemy is called from unassigned members of opposing group");
                }

                if (nearestEnemy != null)
                {
                    // Set both enemies as hostile towards each other
                    enemy.SetHostile(true, nearestEnemy.gameObject);
                    enemy.SetOpponent(nearestEnemy.gameObject);
                    enemy.SetHasOpponent(true);
                    nearestEnemy.SetHostile(true, enemy.gameObject);
                    nearestEnemy.SetOpponent(enemy.gameObject);
                    nearestEnemy.SetHasOpponent(true);
                    Debug.Log($"{enemy.gameObject.name} is now hostile towards {nearestEnemy.gameObject.name}, as the nearest unassigned enemy.");
                }
                else
                {
                    // If no enemy found, set closest enemy as opponent
                    EnemyStateMachine closestEnemy = GetClosestEnemy(enemy);
                    if (closestEnemy != null)
                    {
                        // Set both enemies as hostile towards each other
                        enemy.SetHostile(true, closestEnemy.gameObject);
                        enemy.SetOpponent(closestEnemy.gameObject);
                        enemy.SetHasOpponent(true);
                        closestEnemy.SetHostile(true, enemy.gameObject);
                        closestEnemy.SetOpponent(enemy.gameObject);
                        closestEnemy.SetHasOpponent(true);
                        Debug.Log($"{enemy.gameObject.name} is now hostile towards {closestEnemy.gameObject.name}, as the closest enemy.");
                    }
                }
            }
        }

        // --------------------------------------------------------------------------------------------------------------------------------

        /// <summary>
        /// From the list of enemies that have not been assigned an opponent, who is the closest
        /// enemy that this enemy can go and attack?
        /// </summary>
        /// <param name="enemy"></param>
        /// <param name="unassignedEnemies"></param>
        /// <returns>nearestUnassignedEnemy</returns>
        EnemyStateMachine GetNearestUnassignedEnemy(EnemyStateMachine enemy, List<EnemyStateMachine> unassignedEnemies)
        {
            EnemyStateMachine nearestUnassignedEnemy = null;
            float nearestDistance = Mathf.Infinity;

            foreach (EnemyStateMachine unassignedEnemy in unassignedEnemies) 
            {
                if (unassignedEnemy != null && !unassignedEnemy.GetHasOpponent() && unassignedEnemy.GetAggroGroup() != this) 
                {
                    float distance = Vector3.Distance(enemy.transform.position, unassignedEnemy.transform.position);
                    if (distance < nearestDistance) 
                    {
                        nearestDistance = distance;
                        nearestUnassignedEnemy = unassignedEnemy;
                    }
                }
            }
            return nearestUnassignedEnemy;
        }

        /// <summary>
        /// This function gets the closest enemy to your player,
        /// regardless of whether that enemy is available for a fight or not
        /// (used as a backup in case no enemies from opposing AggroGroup are
        /// available for a fight)
        /// </summary>
        /// <param name="enemy"></param>
        /// <returns></returns>
        EnemyStateMachine GetClosestEnemy(EnemyStateMachine enemy)
        {
            EnemyStateMachine closestEnemy = null;
            float closestDistance = Mathf.Infinity;

            foreach (EnemyStateMachine otherEnemy in enemies)
            {
                if (otherEnemy != null && otherEnemy != enemy && otherEnemy.GetAggroGroup() != this)
                {
                    float distance = Vector3.Distance(enemy.transform.position, otherEnemy.transform.position);
                    if (distance < closestDistance)
                    {
                        closestEnemy = otherEnemy;
                        closestDistance = distance;
                    }
                }
            }
            return closestEnemy;
        }
        // -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

        /// <summary>
        /// This function first finds the AggroGroup of the player, and foreach enemy in there, they will get the Player's Current Opponent.
        /// Each member of the Player's AggroGroup will search for the closest unoccupied enemy to fight, from the team of the Player's Rival.
        /// If it finds one, they'll go for it and fight till death. If it fails, just get the closest enemy it can find from the Player's Opponent's
        /// AggroGroup, and aim for that
        /// </summary>
        private EnemyStateMachine GetClosestEnemyFromPlayerOpponentAggroGroup(EnemyStateMachine playerAllyEnemy)
        {
            EnemyStateMachine closestEnemy = null; // Initialize closest enemy to null
            float closestDistance = Mathf.Infinity; // Initialize closest distance to infinity

            // Player Ally is part of the Player's AggroGroup:
            if (playerAllyEnemy != null && playerAllyEnemy.GetAggroGroup() != null)
            {
                // Get the player's Current Opponent (IT'S NOT USING 'player'. INSTEAD, WE CREATED A NEW ONE FROM SCRATCH. That one is for other types of checks):
                PlayerStateMachine player = FindObjectOfType<PlayerStateMachine>();
                GameObject playerOpponent = player.GetOpponent();

                // Player has an Opponent:
                if (playerOpponent != null)
                {
                    // Get the Player Opponent's AggroGroup:
                    AggroGroup playerOpponentAggroGroup = playerOpponent.GetComponent<EnemyStateMachine>().GetAggroGroup();
                    if (playerOpponentAggroGroup != null)
                    {
                        // If there's only one enemy left in the player's Opponent's AggroGroup, everyone in the group should aim for him:
                        if (playerOpponentAggroGroup.GetGroupMembers().Count(enemy => enemy != null) == 1)
                        {
                            // Aim for that one last enemy:
                            closestEnemy = playerOpponentAggroGroup.GetGroupMembers().FirstOrDefault(enemy => enemy != null);
                            Debug.Log($"{playerAllyEnemy.gameObject.name} is aiming for the last remaining enemy, {closestEnemy.gameObject.name}");
                        }
                        // If more than one enemy is in the group, then allies should each get someone to focus on:
                        else foreach (EnemyStateMachine enemy in playerOpponentAggroGroup.GetGroupMembers())
                        {
                            // Find an unoccupied enemy
                            if (enemy != null && !enemy.GetHasOpponent() && enemy.GetOpponent() == null)
                            {
                                // Calculate distance between playerAllyEnemy and current enemy
                                float distance = Vector3.Distance(playerAllyEnemy.transform.position, enemy.transform.position);
                                // Check if this enemy is closer than the previously closest enemy
                                if (distance < closestDistance)
                                {
                                    // Update closest enemy and closest distance
                                    closestEnemy = enemy;
                                    closestDistance = distance;
                                    Debug.Log($"{playerAllyEnemy.gameObject.name} is aiming for unoccupied enemy {closestEnemy.gameObject.name}");
                                }
                            }
                        }

                        // If no unoccupied enemy was found, aim for the closest enemy in the player's opponent's AggroGroup
                        if (closestEnemy == null)
                        {
                            foreach (EnemyStateMachine enemy in playerOpponent.GetComponent<EnemyStateMachine>().GetAggroGroup().GetGroupMembers())
                            {
                                if (enemy != null)
                                {
                                    float distance = Vector3.Distance(playerAllyEnemy.transform.position, enemy.transform.position);
                                    if (distance < closestDistance)
                                    {
                                        closestEnemy = enemy;
                                        closestDistance = distance;
                                        Debug.Log($"{playerAllyEnemy.gameObject.name} is aiming for closest enemy {closestEnemy.gameObject.name}, regardless of occupation");
                                    }
                                }
                            }
                        }
                    }
                }
            }
            return closestEnemy; // Return the closest enemy found
        }
    }
}

(I know it’s a little too complex, and it can absolutely do a lot more with better coding, as it also contains parts that are responsible for the response of the player’s AggroGroup team depending on circumstances of who just got hit and what not

And since we’re getting a little too complex, that list will also need to be integrated into the Saving and Restoring System)

[Quick question Brian. Can we develop a ‘CaptureAsJToken’ and ‘RestoreFromJToken’ function (I can’t test the final part without saving the game) to store the lists of AggroGroup members, the ‘enemies’ list in the comment right above this one? Literally, all I need is a save and load solution for my saving system, so I know how the dialogue acts when the player goes and comes from the game. PLEASEEEEE]

Another Saving System I might need is one for the ‘AIAggroGroupSwitcher.cs’ script below, so that the game knows which dialogue to play based on whether the NPC holding this script is part of the player’s AggroGroup or not


OK so… to keep things clean, I’m attempting this on my own, and here’s what I’m learning so far:

  1. Create an ‘AIAggroGroupSwitcher.cs’ script. This script is responsible for switching the AggroGroup of whoever holds it. In this case, it’s the NPC with the Dialogue that’s holding it, so he shall be called when this operation happens

In there, there will be two functions, ‘AddNPCToPlayerAggroGroup’ and ‘RemoveNPCFromPlayerAggroGroup’, which will be responsible for, as the name says it, adding and removing the NPCs from your Player’s AggroGroup

And, needless to say, don’t forget in Awake to specify your ‘PlayerAggroGroup’

  1. Create a new Dialogue, and in there create a path for your speech to follow. Once you get there, call the function ‘AddNPCToPlayerAggroGroup’, and also create a path for removing it. I haven’t gotten this far yet, but we’re walking it through…

  2. On the NPC holding the script, include a ‘DialogueTrigger.cs’ script, so you can actually connect the Dialogue to the function that’s calling it

  3. Here’s my current ‘AIAggroGroupSwitcher.cs’ script, finalized ONLY TO ADD NPCs to the Player’s AggroGroup:

using UnityEngine;
using RPG.States.Enemies;
using RPG.Respawnables;

namespace RPG.Combat {

public class AIAggroGroupSwitcher : MonoBehaviour
{
    // This script is placed on NPCs, and is essentially responsible for
    // allowing them to enter and exit the player's AggroGroup, through
    // dialogue:

    private AggroGroup playerAggroGroup;
    private AggroGroup currentAggroGroup;

    void Awake()
    {
        playerAggroGroup = GameObject.Find("PlayerAggroGroup").GetComponent<AggroGroup>();
    }

    public void AddNPCToPlayerAggroGroup()
    {
        Debug.Log($"{gameObject.name} added to player AggroGroup");
        // This function essentially deletes the Enemy 
        // that calls it from his own AggroGroup, and adds him to
        // the Player's AggroGroup, and is meant to be called 
        // at the end of the dialogues that lead to that action:
        if (playerAggroGroup.GetGroupMembers().Contains(this.GetComponent<EnemyStateMachine>())) return; // if this script holder is part of the Player's AggroGroup, don't add him again

        // First, Delete him from his own AggroGroup
        this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().GetComponentInParent<AggroGroup>().RemoveFighterFromGroup(this.GetComponent<EnemyStateMachine>());
        // And now, add him to the player's AggroGroup
        playerAggroGroup.AddFighterToGroup(this.GetComponent<EnemyStateMachine>());
        // Next, update the state of 'currentAggroGroup', a variable to keep track of which team the NPC is on now
        currentAggroGroup = playerAggroGroup;
        // Finally, let 'RespawnManager.cs' know which AggroGroup the Script Holder is on, so it knows how to treat the situationa accordingly
        this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().SetAggroGroup(currentAggroGroup);
    }

    public void RemoveNPCFromPlayerAggroGroup() 
    {
        // Similar to how 'AddNPCToPlayerAggroGroup()' adds the script holder to
        // the player's AggroGroup, this function is supposed to delete the script
        // holder from the Player's AggroGroup
    }

}

}
  1. Throw in a second Dialogue Trigger for ‘RemoveNPCFromPlayerAggroGroup()’ on the NPC holding the script, and again connect that accordingly

  2. In ‘AIConversant.cs’, create a setter for the dialogue of ‘AIConversant.cs’:

        // This setter will set the dialogue, based on the state of the conversant.
        // (For now it's used to change the dialogue for NPCs that join and leave the player's
        // AggroGroup, so we can kick and invite them as we please)
        public void SetDialogue(Dialogue dialogue) 
        {
            this.dialogue = dialogue;
        }

The reason I have a setter is because since I don’t have a predicate for my Dialogue when the enemy is in or out of the team, I decided to just change the entire dialogue and change it in ‘AIConversant.cs’ through code instead. Bear with me, it’ll make more sense right about now

  1. in ‘AIAggroGroupSwitcher.cs’, introduce two new Dialogues to switch between in ‘AIConversant.cs’, so when you’re in the team you got a dialogue, and when you’re about to be invited in the team, you got an entirely different Dialogue, as follows:
    [SerializeField] private RPG.Dialogue.Dialogue NPCOutOfPlayerGroupInviteDialogue;
    [SerializeField] private RPG.Dialogue.Dialogue NPCInPlayerGroupKickOutDialogue;

Obviously I’d appreciate if @Brian_Trotter helped me create a Predicate for that instead (to make my life significantly easier down the line), but for the time being I’m working with what I know

  1. Update your ‘AddNPCToPlayerAggroGroup’ to include the setter, so you can set the dialogue accordingly:
        // Swap the dialogue out for the one where the NPC can be potentially be kicked out of the Player's AggroGroup now:
        conversant.SetDialogue(NPCInPlayerGroupKickOutDialogue);

I’ll upload the full script below when it’s ready (Currently I’m stuck with a funky problem. When the game starts, it uses the RespawnManager to spawn the character. The problem is, it doesn’t know which Dialogue to use, the kick-out or invite… so I’ll need the saving system requested at the very top of this comment to get that). So far, here’s my new ‘AIAggroGroupSwitcher.cs’ script:

using UnityEngine;
using RPG.States.Enemies;
using RPG.Respawnables;
using RPG.Dialogue;

namespace RPG.Combat {

public class AIAggroGroupSwitcher : MonoBehaviour
{
    // This script is placed on NPCs, and is essentially responsible for
    // allowing them to enter and exit the player's AggroGroup, through
    // dialogue:

    private AggroGroup playerAggroGroup;
    private AggroGroup currentAggroGroup;
    private AIConversant conversant;
    private Dialogue.Dialogue currentConversation;

    [SerializeField] private RPG.Dialogue.Dialogue NPCOutOfPlayerGroupInviteDialogue;
    [SerializeField] private RPG.Dialogue.Dialogue NPCInPlayerGroupKickOutDialogue;

    void Awake()
    {
        playerAggroGroup = GameObject.Find("PlayerAggroGroup").GetComponent<AggroGroup>();
        conversant = GetComponent<AIConversant>();
        if (currentConversation == null) currentConversation = NPCOutOfPlayerGroupInviteDialogue;
    }

    public void AddNPCToPlayerAggroGroup()
    {
        Debug.Log($"{gameObject.name} added to player AggroGroup");
        // This function essentially deletes the Enemy 
        // that calls it from his own AggroGroup, and adds him to
        // the Player's AggroGroup, and is meant to be called 
        // at the end of the dialogues that lead to that action:
        if (playerAggroGroup.GetGroupMembers().Contains(this.GetComponent<EnemyStateMachine>())) return; // if this script holder is part of the Player's AggroGroup, don't add him again

        // First, Delete him from his own AggroGroup
        this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().GetComponentInParent<AggroGroup>().RemoveFighterFromGroup(this.GetComponent<EnemyStateMachine>());
        // And now, add him to the player's AggroGroup
        playerAggroGroup.AddFighterToGroup(this.GetComponent<EnemyStateMachine>());
        // Next, update the state of 'currentAggroGroup', a variable to keep track of which team the NPC is on now
        currentAggroGroup = playerAggroGroup;
        // Finally, let 'RespawnManager.cs' know which AggroGroup the Script Holder is on, so it knows how to treat the situationa accordingly
        this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().SetAggroGroup(currentAggroGroup);
        // Swap the dialogue out for the one where the NPC can be potentially be kicked out of the Player's AggroGroup now:
        conversant.SetDialogue(NPCInPlayerGroupKickOutDialogue);
        // Set the Current Conversation, so the Respawn Manager knows what to use when respawning
        currentConversation = NPCInPlayerGroupKickOutDialogue;
    }

    public void RemoveNPCFromPlayerAggroGroup()
    {
        Debug.Log($"{gameObject.name} kicked out of player AggroGroup");
        // Similar to how 'AddNPCToPlayerAggroGroup()' adds the script holder to
        // the player's AggroGroup, this function is supposed to delete the script
        // holder from the Player's AggroGroup:

        if (!playerAggroGroup.GetGroupMembers().Contains(this.GetComponent<EnemyStateMachine>())) return; // if this script holder is no longer part of the Player's AggroGroup, don't try deleting him again

        // First, Add the NPC back to his original Individual AggroGroup
        this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().GetComponentInParent<AggroGroup>().AddFighterToGroup(this.GetComponent<EnemyStateMachine>());
        // Remove the NPC from the Player's AggroGroup
        playerAggroGroup.RemoveFighterFromGroup(this.GetComponent<EnemyStateMachine>());
        // Update the current AggroGroup, for the RespawnManager
        currentAggroGroup = this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().GetComponentInParent<AggroGroup>();
        // let 'RespawnManager.cs' know which AggroGroup the Script holder is on, so it treats the situation accordingly
        this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().SetAggroGroup(currentAggroGroup);
        // Swap the dialogue out for the one to invite the NPC to the Player's AggroGroup
        conversant.SetDialogue(NPCOutOfPlayerGroupInviteDialogue);
        // Set the Current Conversation, so the Respawn Manager knows what to use when respawning
        currentConversation = NPCOutOfPlayerGroupInviteDialogue;
    }

    public Dialogue.Dialogue GetCurrentConversation() 
    {
        return currentConversation;
    }

}

}

and my ‘AggroGroup.cs’ (again):

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using Unity.VisualScripting;
using RPG.Attributes;
using RPG.States.Enemies;
using RPG.States.Player;

namespace RPG.Combat
{

    public class AggroGroup : MonoBehaviour
    {
        [Header("The Player's Individual AggroGroup MUST BE A PARENT OF THE PLAYER,\notherwise the first if statement in the first Foreach loop\nin the first if statement in 'OnTakenHit' will act up!")]

        [Header("This script aggrevates a group of enemies,\n if you attack one of their friends.\n The player must be in their 'PlayerChasingRange' though\n otherwise they won't try attacking you")]

        [Tooltip("The list of enemies this AggroGroup has")]
        [SerializeField] List<EnemyStateMachine> enemies = new List<EnemyStateMachine>();

        [Tooltip("Set this to true only for groups of enemies that you want naturally aggressive towards the player")]
        [SerializeField] bool groupHatesPlayer = false;

        [Tooltip("Set to true only if you want enemies to forget any problems they had with the player when he dies")]
        [SerializeField] bool resetIfPlayerDies = false; // this is left as a boolean because it allows us to choose which aggroGroup members reset their settings on players' death, and which groups may not reset themselves on death (depending on your games' difficulty)

        [Header("IF THE PLAYER IS NOT SUPPOSED TO BE NULL FOR THIS GROUP, THEN\nKEEP THIS AS A PARENT OF THE PLAYER, OTHERWISE\nIT WILL MESS EVERYTHING UP!")]
        [Tooltip("Is the player a part of this aggroGroup? If so, it will be placed here through code")]
        [SerializeField] PlayerStateMachine player;

        private Health playerHealth;
        private AggroGroup playerAggroGroup;

        void Awake() 
        {
            // Getting the player to be in his individual AggroGroup, which is the group parenting the player:
            if (this.GetComponentInChildren<PlayerStateMachine>()) player = GameObject.FindWithTag("Player").GetComponent<PlayerStateMachine>();
            // Getting the Player's AggroGroup:
            playerAggroGroup = GameObject.FindWithTag("Player").GetComponentInParent<AggroGroup>();
        }

        /// <summary>
        /// At the start of the game
        /// </summary>
        private void Start()
        {
            // If the entire group hates the player, they're all set to hostile, 
            // otherwise everyone is at their initial hostility:
            if (groupHatesPlayer) Activate(true);
            else foreach (EnemyStateMachine enemy in enemies) // this can't go to 'EnemyStateMachine.cs'. Doing so will conflict with 'RespawnManager.cs''s hostility. It stays here
            {
                // if the enemy is part of the same AggroGroup as the player, deactivate Hostility (and do that when Respawned as well)
                // otherwise set Hostility to default hostility setting, if that enemy is not in the same AggroGroup as the player
                if (enemy.GetAggroGroup().GetPlayer() != null) enemy.SetHostile(false);
                else enemy.SetHostile(enemy.GetInitialHostility);
            }
        }

        public void OnEnable()
        {
            if (resetIfPlayerDies)
            {
                playerHealth = GameObject.FindGameObjectWithTag("Player").GetComponent<Health>();
                if (playerHealth != null) playerHealth.onDie.AddListener(ResetGroupMembers);
            }
        }

        public void OnDisable()
        {
            playerHealth.onDie.RemoveListener(ResetGroupMembers);
        }

        /// <summary>
        /// Is this enemy within the same AggroGroup as the player?
        /// </summary>
        /// <returns>Player GameObject</returns>
        public PlayerStateMachine GetPlayer() 
        {
            return player;
        }

        /// <summary>
        /// Called when the player dies, and everyone is reset to their original state (resetIfPlayerDies must be set to true)
        /// </summary>
        public void ResetGroupMembers()
        {
            if (groupHatesPlayer) Activate(true);

            else if (playerHealth != null) 
            {
            foreach (EnemyStateMachine enemy in enemies)
            {
                enemy.SetHostile(enemy.GetInitialHostility);
            }
            }
        }

        /// <summary>
        /// Used when you're sure the WHOLE GROUP either likes you, or hates you (otherwise use 'EnemyStateMachine.SetHostile' for individuals)
        /// </summary>
        /// <param name="shouldActivate"></param>
        public void Activate(bool shouldActivate)
        {
            enemies.RemoveAll(enemy => enemy == null || enemy.IsDestroyed());

            foreach (EnemyStateMachine enemy in enemies)
            {
                enemy.SetHostile(shouldActivate);
            }
        }

        public void AddFighterToGroup(EnemyStateMachine enemy)
        {
            // If you got the fighter you want to add on your list, return:
            if (enemies.Contains(enemy)) return;
            // For other fighters on the list, add them:
            enemy.SetAggroGroup(this);
            enemies.Add(enemy);
            enemy.GetComponent<Health>().OnTakenHit += OnTakenHit;
        }

        public void RemoveFighterFromGroup(EnemyStateMachine enemy)
        {
            // if the enemy is gone, don't try removing him again:
            if (enemy == null) return;
            // else Remove fighters from the list
            enemy.SetAggroGroup(null);
            enemies.Remove(enemy);
            enemy.GetComponent<Health>().OnTakenHit -= OnTakenHit;
        }

        public bool HasMember(EnemyStateMachine enemy) 
        {
            return enemies.Contains(enemy);
        }

        public List<EnemyStateMachine> GetGroupMembers() 
        {
            if (enemies == null) return null;
            else return enemies;
        }

        private bool isDead;

        public bool GetIsDead()
        {
            return isDead;
        }

        public void SetIsDead(bool isDead)
        {
            this.isDead = isDead;
        }

        /// <summary>
        /// This Update function forces the enemy to delete any 'Missing'
        /// Opponent gameObjects they might have, ensuring that they return
        /// to their normal action if an enemy went missing, or is dead (for those stupid walking corpses,
        /// I need to deal with that later...)
        /// </summary>
        void Update()
        {
            // 'IsDead' is a boolean flag. The reason it's here is to ensure this function is called
            // ONCE, AND ONCE ONLY, when an enemy is dead, to get everyone to forget about him. If you don't
            // do that, it'll call repetitively and cause some performance issues:

            // IsDead is called from 'EnemyStateMachine.OnDie()', when the enemy dies, and it's turned back off
            // here after the data of the dead enemies' hunters is wiped out:
            if (isDead)
            {
                foreach (EnemyStateMachine enemy in FindObjectsOfType<EnemyStateMachine>())
                {                                   // just to make sure we don't accidentally stalk ghost enemies...
                    if (enemy.GetOpponent() != null && enemy.GetOpponent().GetComponent<Health>().IsDead())
                    {
                        // if you're in the Player's AggroGroup, you have special treatment for hostility,
                        // as you can't be hostile against the player, who is your Ally
                        if (enemy.GetAggroGroup() != null && enemy.GetAggroGroup().GetPlayer())
                        {
                            Debug.Log($"{enemy.gameObject.name} is getting hostility set to false");
                            enemy.SetHostile(false, null);
                            enemy.SetOpponent(null);
                            enemy.SetHasOpponent(false);
                            isDead = false;
                        }
                        // if you're not in the Player's AggroGroup, just be normal
                        else
                        {
                            Debug.Log($"{enemy.gameObject.name} is getting hostility set to initial hostility");
                            enemy.SetHostile(enemy.GetInitialHostility);
                            enemy.SetOpponent(null);
                            enemy.SetHasOpponent(false);
                            isDead = false;
                        }
                    }
                }
                return;
            }
        }

        // -------------------------------- Want Superior AI Intelligence? THIS IS IT! ----------------- 
        // The Final Version of 'OnTakenHit()'. This version does what the third version did, but can also deal
        // with cases where the player is part of an AggroGroup, and needs to call his allies when he's under attack ---------------------------

        /// <summary>
        /// This is the smartest version of 'OnTakenHit'. Essentially, it initially checks for if the Player was the one 
        /// who instigated the damage. If true, the player's allies will aim for the nearest enemies of the player's
        /// Opponent's AggroGroup team they can find.
        /// If the player is solo, all enemies in the enemy's 'PlayerChaseRadius' radius will attempt to attack 
        /// the Player. If the instigator is an enemy, get the nearest unassigned enemy you can find 
        /// in the Enemy's AggroGroup, and aim for that. If you can't find any, 
        /// get the closest enemy you can find
        /// </summary>
        /// <param name="instigator"></param>
        void OnTakenHit(GameObject instigator)
        {
            // Check if the instigator is the player, or anyone in the player's team:
            if (instigator.GetComponent<PlayerStateMachine>())
            {
                // If the instigator is in player chasing range, then all enemies in this victim's AggroGroup, within Player Chasing Range, with no opponent, shall aim for the player (regardless of Hostility):
                foreach (EnemyStateMachine enemy in enemies.Where(enemy => enemy != null && enemy.GetAggroGroup() == this))
                {
                    Debug.Log("We have entered the foreach loop");
                    // if the victim of the player is not part of his AggroGroup (i.e: "GetPlayer() == null"),
                    // enemies in the players' AggroGroup shall aim
                    // for the victim's team members, and this shall turn into a big fight:
                    if (GetPlayer() == null)
                    {
                        Debug.Log($"PlayerAggroGroup: {playerAggroGroup.gameObject.name}");

                        // Player hit someone from outside:
                        if (playerAggroGroup != null && playerAggroGroup != this)
                        {
                            Debug.Log("Not part of the Player's AggroGroup");
                            foreach (EnemyStateMachine playerAllyEnemy in playerAggroGroup.GetGroupMembers().Where(playerAllyEnemy => playerAllyEnemy != null))
                            {
                                Debug.Log("PlayerAggroGroup members accessed");
                                // All available enemies in PlayerAggroGroup should get ready to fight:
                                if (playerAllyEnemy != null && playerAllyEnemy.GetOpponent() == null && !playerAllyEnemy.GetHasOpponent())
                                {
                                    EnemyStateMachine closestEnemy = GetClosestEnemyFromPlayerOpponentAggroGroup(playerAllyEnemy);
                                    Debug.Log("Closest Enemy for the Player's Ally Enemy has been accumulated");

                                    // FIGHT (turn on enemy-checking here, so that enemies who are unavailable are eliminated):
                                    if (closestEnemy != null /* && closestEnemy.GetOpponent() == null */)
                                    {
                                        // Player's ally targets the closest enemy in the Victim's AggroGroup:
                                        playerAllyEnemy.SetHasOpponent(true);
                                        playerAllyEnemy.SetOpponent(closestEnemy.gameObject);
                                        playerAllyEnemy.SetHostile(true, closestEnemy.gameObject);

                                        // the Closest Enemy in the Victim's AggroGroup should aim for the Player's ally as well:
                                        closestEnemy.SetHasOpponent(true);
                                        closestEnemy.SetOpponent(playerAllyEnemy.gameObject);
                                        closestEnemy.SetHostile(true, playerAllyEnemy.gameObject);
                                    }

                                    Debug.Log($"{playerAllyEnemy.gameObject.name} is now hostile towards {enemy.gameObject.name}, as they are not part of our AggroGroup");
                                    Debug.Log($"{enemy.gameObject.name} is now hostile towards {playerAllyEnemy.gameObject.name}, as they are not part of the AggroGroup");
                                }
                            }
                        }
                        else {Debug.Log($"Player's AggroGroup either not found, or is the same as this one");}
                    }

                    // For cases where I have no opponent, and the player within my chase range has hit my ally, I shall aim for the player
                    // (and surprisingly, it works beautifully for both the player allies and enemies, so we can essentially
                    // get player allies to flip on him if he tries to flip on one of them, and they're not fighting anyone else
                    // (kind of like a 'penalty for trying to betray the team' system)):
                    if (enemy.GetOpponent() == null && !enemy.GetHasOpponent() && enemy.IsInPlayerChasingRange())
                    {
                        // 'EnemyStateMachine.ClearPlayer()' takes care of individually clearing up enemies when the player gets out of Chase Range
                        // and is called in 'EnemyChasingState.cs'
                        enemy.SetHostile(true, instigator.gameObject);
                        enemy.SetOpponent(instigator.gameObject);
                        enemy.SetHasOpponent(true);

                        Debug.Log($"{enemy.gameObject.name} is now hostile towards the player In Chase Range.");
                    }
                }
                return;
            }

            // BELOW IS THE CASE FOR WHEN THE INSTIGATOR WAS ANOTHER NPC, NOT THE PLAYER:

            // Get the (instigator) attacker's aggro group, who is NOT the player:
            AggroGroup attackerAggroGroup = instigator.GetComponent<EnemyStateMachine>()?.GetAggroGroup();

            foreach (EnemyStateMachine enemy in this.enemies)
            {
                // Find the nearest enemy from the attacker's group
                EnemyStateMachine nearestEnemy = null;
                if (attackerAggroGroup != null && attackerAggroGroup != this)
                {
                    nearestEnemy = GetNearestUnassignedEnemy(enemy, attackerAggroGroup.GetGroupMembers());
                    Debug.Log($"nearestEnemy is called from unassigned members of opposing group");
                }

                if (nearestEnemy != null)
                {
                    // Set both enemies as hostile towards each other
                    enemy.SetHostile(true, nearestEnemy.gameObject);
                    enemy.SetOpponent(nearestEnemy.gameObject);
                    enemy.SetHasOpponent(true);
                    nearestEnemy.SetHostile(true, enemy.gameObject);
                    nearestEnemy.SetOpponent(enemy.gameObject);
                    nearestEnemy.SetHasOpponent(true);
                    Debug.Log($"{enemy.gameObject.name} is now hostile towards {nearestEnemy.gameObject.name}, as the nearest unassigned enemy.");
                }
                else
                {
                    // If no enemy found, set closest enemy as opponent
                    EnemyStateMachine closestEnemy = GetClosestEnemy(enemy);
                    if (closestEnemy != null)
                    {
                        // Set both enemies as hostile towards each other
                        enemy.SetHostile(true, closestEnemy.gameObject);
                        enemy.SetOpponent(closestEnemy.gameObject);
                        enemy.SetHasOpponent(true);
                        closestEnemy.SetHostile(true, enemy.gameObject);
                        closestEnemy.SetOpponent(enemy.gameObject);
                        closestEnemy.SetHasOpponent(true);
                        Debug.Log($"{enemy.gameObject.name} is now hostile towards {closestEnemy.gameObject.name}, as the closest enemy.");
                    }
                }
            }
        }

        // --------------------------------------------------------------------------------------------------------------------------------

        /// <summary>
        /// From the list of enemies that have not been assigned an opponent, who is the closest
        /// enemy that this enemy can go and attack?
        /// </summary>
        /// <param name="enemy"></param>
        /// <param name="unassignedEnemies"></param>
        /// <returns>nearestUnassignedEnemy</returns>
        EnemyStateMachine GetNearestUnassignedEnemy(EnemyStateMachine enemy, List<EnemyStateMachine> unassignedEnemies)
        {
            EnemyStateMachine nearestUnassignedEnemy = null;
            float nearestDistance = Mathf.Infinity;

            foreach (EnemyStateMachine unassignedEnemy in unassignedEnemies) 
            {
                if (unassignedEnemy != null && !unassignedEnemy.GetHasOpponent() && unassignedEnemy.GetAggroGroup() != this) 
                {
                    float distance = Vector3.Distance(enemy.transform.position, unassignedEnemy.transform.position);
                    if (distance < nearestDistance) 
                    {
                        nearestDistance = distance;
                        nearestUnassignedEnemy = unassignedEnemy;
                    }
                }
            }
            return nearestUnassignedEnemy;
        }

        /// <summary>
        /// This function gets the closest enemy to your player,
        /// regardless of whether that enemy is available for a fight or not
        /// (used as a backup in case no enemies from opposing AggroGroup are
        /// available for a fight)
        /// </summary>
        /// <param name="enemy"></param>
        /// <returns></returns>
        EnemyStateMachine GetClosestEnemy(EnemyStateMachine enemy)
        {
            EnemyStateMachine closestEnemy = null;
            float closestDistance = Mathf.Infinity;

            foreach (EnemyStateMachine otherEnemy in enemies)
            {
                if (otherEnemy != null && otherEnemy != enemy && otherEnemy.GetAggroGroup() != this)
                {
                    float distance = Vector3.Distance(enemy.transform.position, otherEnemy.transform.position);
                    if (distance < closestDistance)
                    {
                        closestEnemy = otherEnemy;
                        closestDistance = distance;
                    }
                }
            }
            return closestEnemy;
        }
        // -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

        /// <summary>
        /// This function first finds the AggroGroup of the player, and foreach enemy in there, they will get the Player's Current Opponent.
        /// Each member of the Player's AggroGroup will search for the closest unoccupied enemy to fight, from the team of the Player's Rival.
        /// If it finds one, they'll go for it and fight till death. If it fails, just get the closest enemy it can find from the Player's Opponent's
        /// AggroGroup, and aim for that
        /// </summary>
        private EnemyStateMachine GetClosestEnemyFromPlayerOpponentAggroGroup(EnemyStateMachine playerAllyEnemy)
        {
            EnemyStateMachine closestEnemy = null; // Initialize closest enemy to null
            float closestDistance = Mathf.Infinity; // Initialize closest distance to infinity

            // Player Ally is part of the Player's AggroGroup:
            if (playerAllyEnemy != null && playerAllyEnemy.GetAggroGroup() != null)
            {
                // Get the player's Current Opponent (IT'S NOT USING 'player'. INSTEAD, WE CREATED A NEW ONE FROM SCRATCH. That one is for other types of checks):
                PlayerStateMachine player = FindObjectOfType<PlayerStateMachine>();
                GameObject playerOpponent = player.GetOpponent();

                // Player has an Opponent:
                if (playerOpponent != null)
                {
                    // Get the Player Opponent's AggroGroup:
                    AggroGroup playerOpponentAggroGroup = playerOpponent.GetComponent<EnemyStateMachine>().GetAggroGroup();
                    if (playerOpponentAggroGroup != null)
                    {
                        // If there's only one enemy left in the player's Opponent's AggroGroup, everyone in the group should aim for him:
                        if (playerOpponentAggroGroup.GetGroupMembers().Count(enemy => enemy != null) == 1)
                        {
                            // Aim for that one last enemy:
                            closestEnemy = playerOpponentAggroGroup.GetGroupMembers().FirstOrDefault(enemy => enemy != null);
                            Debug.Log($"{playerAllyEnemy.gameObject.name} is aiming for the last remaining enemy, {closestEnemy.gameObject.name}");
                        }
                        // If more than one enemy is in the group, then allies should each get someone to focus on:
                        else foreach (EnemyStateMachine enemy in playerOpponentAggroGroup.GetGroupMembers())
                        {
                            // Find an unoccupied enemy
                            if (enemy != null && !enemy.GetHasOpponent() && enemy.GetOpponent() == null)
                            {
                                // Calculate distance between playerAllyEnemy and current enemy
                                float distance = Vector3.Distance(playerAllyEnemy.transform.position, enemy.transform.position);
                                // Check if this enemy is closer than the previously closest enemy
                                if (distance < closestDistance)
                                {
                                    // Update closest enemy and closest distance
                                    closestEnemy = enemy;
                                    closestDistance = distance;
                                    Debug.Log($"{playerAllyEnemy.gameObject.name} is aiming for unoccupied enemy {closestEnemy.gameObject.name}");
                                }
                            }
                        }

                        // If no unoccupied enemy was found, aim for the closest enemy in the player's opponent's AggroGroup
                        if (closestEnemy == null)
                        {
                            foreach (EnemyStateMachine enemy in playerOpponent.GetComponent<EnemyStateMachine>().GetAggroGroup().GetGroupMembers())
                            {
                                if (enemy != null)
                                {
                                    float distance = Vector3.Distance(playerAllyEnemy.transform.position, enemy.transform.position);
                                    if (distance < closestDistance)
                                    {
                                        closestEnemy = enemy;
                                        closestDistance = distance;
                                        Debug.Log($"{playerAllyEnemy.gameObject.name} is aiming for closest enemy {closestEnemy.gameObject.name}, regardless of occupation");
                                    }
                                }
                            }
                        }
                    }
                }
            }
            return closestEnemy; // Return the closest enemy found
        }
    }
}

and my ‘RespawnManager.cs’, if needed:

using System.Collections.Generic;
using GameDevTV.Saving;
using RPG.Control;
using UnityEngine;
using RPG.Combat;
using Newtonsoft.Json.Linq;
using RPG.States.Enemies;
using System.Collections;
using System;
using RPG.Dialogue;

namespace RPG.Respawnables
{
    [RequireComponent(typeof(JSONSaveableEntity))]
    public class RespawnManager : MonoBehaviour, IJsonSaveable
    {
        [SerializeField] EnemyStateMachine spawnableEnemy;

        [SerializeField] private float hideTime;   // time before hiding our dead character
        [SerializeField] private float respawnTime;    // time before respawning our hidden dead character, as another alive, respawned character
        [SerializeField] PatrolPath patrolPath; // the path our character will follow, from his spawn point
        [SerializeField] AggroGroup aggroGroup; // aggrevated group of guards, based on wrong dialogue player has said
        [SerializeField] bool hasBeenRestored;  // checks if the enemy has been restored before fading in from the main menu, or a load scene, or not

        // private AIController spawnedEnemy; // changed to EnemyStateMachine type below
        // private EnemyStateMachine spawnedEnemy; // in-game instance of the enemy

        // TEST (TimeKeeper)
        private double destroyedTime;
        private TimeKeeper timeKeeper;

        // --------------------------- NOTE: RestoreState() occurs BEFORE Start(), hence we need to change Start() to Awake() --------------
        private void Awake()
        {
            // TEST
            timeKeeper = TimeKeeper.GetTimeKeeper();

            // Check if the Enemy has been restored first or not, prior to Respawning him (ensuring RestoreState(), which occurs first, works properly)
            if (!hasBeenRestored) Respawn();
        }
        // --------------------------------------------------------------------------------------------------------------------------------

        private void Respawn()
        {
            var spawnedEnemy = GetSpawnedEnemy();

            if (spawnedEnemy != null)
            {
                // If you found an enemy as a child of this script holder, delete his 'OnDeath' listener
                spawnedEnemy.Health.onDie.RemoveListener(OnDeath);
            }

            foreach (Transform child in transform)
            {
                // Start the Respawn by deleting any existing gameObjects
                Destroy(child.gameObject);
            }

            spawnedEnemy = Instantiate(spawnableEnemy, transform);

            // If the enemy has a weapon that's supposed to be in his hand, make him wear it:
            if (spawnedEnemy.GetComponent<Fighter>().GetCurrentWeaponConfig() != null)
            {
                WeaponConfig enemyWeaponConfig = spawnedEnemy.GetComponent<Fighter>().currentWeaponConfig;
                spawnedEnemy.GetComponent<Fighter>().AttachWeapon(enemyWeaponConfig);
            }

            // Get the spawned/respawned enemies' health, and listen for death notifications
            spawnedEnemy.Health.onDie.AddListener(OnDeath);

            if (patrolPath != null)
            {
                Debug.Log($"Assigning Patrol Path {patrolPath} to {spawnedEnemy.name}");
                spawnedEnemy.AssignPatrolPath(patrolPath);
                // set the enemy's initial hostility here instead of 'EnemyStateMachine.Start()', otherwise the override will mess with your head for days 
                // (I made that mistake, hence why I'm writing about it here):
                // if the spawned Enemy is part of the Player's AggroGroup, turn off his hostility on respawn against the Player:
                if (spawnedEnemy != null && spawnedEnemy.GetAggroGroup() != null && spawnedEnemy.GetAggroGroup().GetPlayer()) spawnedEnemy.SetHostile(false);
                else spawnedEnemy.SetHostile(spawnedEnemy.GetInitialHostility);
                spawnedEnemy.SwitchState(new EnemyIdleState(spawnedEnemy));
            }
            else
            {
                Debug.Log($"No Patrol Path to assign");
            }

            // For Dialogue-Powered NPCs, you need to find a way to get the proper conversation for their state, based on whether they're on your team or not:
            if (GetComponentInChildren<AIConversant>() != null)
            {
                GetComponentInChildren<AIConversant>().SetDialogue(GetComponentInChildren<AIAggroGroupSwitcher>().GetCurrentConversation());
            }

            // --------------------------- Extra Functionality: Setting up Aggro Group + Adding Fighters ---------------
            if (aggroGroup != null)
            {
                aggroGroup.AddFighterToGroup(spawnedEnemy);

                // -------------------------------- TEST AREA ------------------------------------------------------------------------------------------------------------------------

                // get the AggroGroup on Respawn, so when the enemy returns to life, he can go through the list of allies, and if any of them are under attack, he can try fight with them
                spawnedEnemy.SetAggroGroup(spawnedEnemy.GetAggroGroup());

                if (spawnedEnemy.GetAggroGroup() != null)
                {
                    foreach (EnemyStateMachine allyEnemy in spawnedEnemy.GetAggroGroup().GetGroupMembers())
                    {
                        if (spawnedEnemy != null && spawnedEnemy.GetAggroGroup().GetPlayer()) spawnedEnemy.SetHostile(false);
                        // a friend of the NPC that just respawned is under attack, so that just-respawned NPC should go and fight for them:
                        else if (allyEnemy != null && allyEnemy != spawnedEnemy && allyEnemy.GetOpponent() != null && allyEnemy.GetOpponent().GetComponent<EnemyStateMachine>().GetAggroGroup() != spawnedEnemy.GetAggroGroup())
                        {
                            // aim for whoever is attacking your allies, and then break after finding the first one:
                            spawnedEnemy.SetOpponent(allyEnemy.GetOpponent());
                            spawnedEnemy.SetHasOpponent(true);
                            // Find a way to set the hostility of respawned player allies here to false:
                            spawnedEnemy.SetHostile(true, allyEnemy.GetOpponent());
                            Debug.Log($"{spawnedEnemy.gameObject.name} Respawned and is supposed to fight... SetHostile set to {spawnedEnemy.IsHostile}");
                            break;
                        }
                    }
                }

                // -------------------------------------- END OF TEST AREA -----------------------------------------------------------------------------------------------------------

                if (spawnedEnemy.TryGetComponent(out DialogueAggro dialogueAggro)) //aggrogroup is at this point valid
                {
                    dialogueAggro.SetAggroGroup(aggroGroup);
                }
            }
            // ---------------------------------------------------------------------------------------------------------
        }

        public void SetAggroGroup(AggroGroup aggroGroup) 
        {
            // This setter is used in 'AIAggroGroupSwitcher.cs', essentially to
            // let this Respawn Manager know which AggroGroup to load the Respawned
            // enemy to
            this.aggroGroup = aggroGroup;
        }

    private IEnumerator HideCharacter()
    {
        Debug.Log($"HideCharacter in progress");
        var spawnedEnemy = GetSpawnedEnemy();
        if (spawnedEnemy == null) yield break;
        spawnedEnemy.transform.SetParent(null);
        yield return new WaitForSecondsRealtime(hideTime);
        Destroy(spawnedEnemy.gameObject);
    }

    void OnDeath()
    {
        var spawnedEnemy = GetSpawnedEnemy();

        if (spawnedEnemy != null)
        {
            spawnedEnemy.Health.onDie.RemoveListener(OnDeath);
            StartCoroutine(HideCharacter());
            destroyedTime = timeKeeper.GetGlobalTime();
            StartCoroutine(WaitAndRespawn());
            if (aggroGroup != null)
            {
                aggroGroup.RemoveFighterFromGroup(spawnedEnemy);
            }
        }
    }

    private IEnumerator WaitAndRespawn()
    {
        var elapsedTime = (float)(timeKeeper.GetGlobalTime() - destroyedTime);
        yield return new WaitForSecondsRealtime(respawnTime - elapsedTime);
        Respawn();
    }

    private bool IsDead()
    {
        var spawnedEnemy = GetSpawnedEnemy();
        return spawnedEnemy == null || spawnedEnemy.Health.IsDead();
    }

    private EnemyStateMachine GetSpawnedEnemy()
    {
        return GetComponentInChildren<EnemyStateMachine>();
    }

    public JToken CaptureAsJToken()
    {
            JObject state = new JObject();
            IDictionary<string, JToken> stateDict = state;

            // TEST (Adding data to the JObject Dictionary):
            var isDead = IsDead();
            var data = new RespawnData(destroyedTime, isDead);
            stateDict["RespawnData"] = JToken.FromObject(data);

            // we only care about data of alive enemies
            if (!isDead)
            {
                var spawnedEnemy = GetSpawnedEnemy();
                foreach (IJsonSaveable JSONSaveable in spawnedEnemy.GetComponents<IJsonSaveable>())
                {
                    JToken token = JSONSaveable.CaptureAsJToken();
                    string component = JSONSaveable.GetType().ToString();
                    Debug.Log($"{name} Capture {component} = {token.ToString()}");
                    stateDict[component] = token;
                }
            }
            return state;
    }

        public void RestoreFromJToken(JToken s)
        {
            
            JObject state = s.ToObject<JObject>();
            IDictionary<string, JToken> stateDict = state;

            var data = default(RespawnData);
            if (stateDict.TryGetValue("RespawnData", out var dataToken))
            {
                data = dataToken.ToObject<RespawnData>();
            }

            var isDead = data.IsDead;
            destroyedTime = data.DestroyedTime;

            // Should be dead
            if (isDead && !IsDead())
            {
                Debug.Log("Should be dead, but isn't...");
                var spawnedEnemy = GetSpawnedEnemy();
                Debug.Log($"Listeners before: {spawnedEnemy.Health.onDie.GetPersistentEventCount()}");
                spawnedEnemy.Health.onDie.RemoveListener(OnDeath);
                Debug.Log($"Listeners after: {spawnedEnemy.Health.onDie.GetPersistentEventCount()}");
                Debug.Log($"Health Before: {spawnedEnemy.Health.GetHealthPoints()}");
                spawnedEnemy.Health.Kill();
                Debug.Log($"Health After: {spawnedEnemy.Health.GetHealthPoints()}");
                StartCoroutine(WaitAndRespawn());
                if (aggroGroup != null)
                {
                    aggroGroup.RemoveFighterFromGroup(spawnedEnemy);
                }
                StartCoroutine(HideCharacter());
                // HideCharacter();
                Debug.Log($"Spawned Enemy: {GetSpawnedEnemy()}");
            }
            else if (isDead && IsDead())
            {
                Debug.Log("Should be dead, and is indeed dead...");
                StopAllCoroutines();
                StartCoroutine(WaitAndRespawn());
                StartCoroutine(HideCharacter());
                // HideCharacter();
            }
            // Should be alive
            else if (!isDead && IsDead())
            {
                Debug.Log("Shouldn't be dead, but is dead...");
                Respawn();
                LoadEnemyState(stateDict);
            }
            else
            {
                Debug.Log("Shouldn't be dead, and isn't dead...");
                LoadEnemyState(stateDict);
            }
        }

        private void LoadEnemyState(IDictionary<string, JToken> stateDict)
        {
            var spawnedEnemy = GetSpawnedEnemy();
            foreach (IJsonSaveable jsonSaveable in spawnedEnemy.GetComponents<IJsonSaveable>())
            {
                string component = jsonSaveable.GetType().ToString();
                if (stateDict.ContainsKey(component))
                {
                    Debug.Log($"{name} Restore {component} => {stateDict[component].ToString()}");
                    jsonSaveable.RestoreFromJToken(stateDict[component]);
                }
            }
        }
    }

    [Serializable]
    public struct RespawnData
    {
        public bool IsDead;
        public double DestroyedTime;

        public RespawnData(double destroyedTime, bool isDead)
        {
            IsDead = isDead;
            DestroyedTime = destroyedTime;
        }
    }
}

All I want to do here is find a way to connect the correct dialogue, with the correct status. So if the NPC is part of the player’s AggroGroup, when the player returns to the game, get the conversation that can potentially kick the NPC out of the Player’s AggroGroup.

And if the NPC is NOT part of the player’s AggroGroup, when the player returns to the game and loads it up, it should fire the dialogue that essentially invites the NPC to the Player’s AggroGroup

The same case should go for Respawning. This one is a major problem for me, because I am clueless as to how to get the correct ‘currentConversation’ status from ‘AIAggroGroupSwitcher.cs’ for that

And more importantly, be able to save and load the ‘enemies’ List on my ‘AggroGroup.cs’ script, so we know who is where

And before I forget. Just to give you an idea of my hierarchy of any NPC in the game, it’s split to 3 parts:

  1. The uppermost empty gameObject is the ‘AggroGroup.cs’. The plan is to have every character in the game have an AggroGroup.cs script as a header, and that can be empty or full, depending on who is on the team

  2. The second layer is the ‘RespawnManager.cs’, this is responsible for respawning the player at the start of the game, and when he dies

  3. The third layer, only instantiated when the game runs, is the character and all of his data beneath him


Edit 2: I’m stuck with a new unforeseen problem. When the enemy dies and resets, it’ll use ‘currentConversation’ based on what AIAggroGroupSwitcher.Awake() says (which is the ‘invite’ dialogue, regardless of whether he’s already on the team or not… I need to find a way to update that as well), which is expected, but I want to update the conversation based on whether he was part of the player’s AggroGroup or not… (and unfortunately when he dies, he has to be deleted off the list for performance reasons, so this really makes things harder for me to program)

This just keeps getting harder the more I try. Please send help

For the most part, this is more than I have time for at the moment… I’ll look it over again this weekend, but I make no guarantees.

If the AgrogroupSwitcher is on each npc that is in an Aggrogroup, then an IsInPlayerAggroGroup predicate would return

playerAggroGroup.GetGroupMembers().Contains(this.GetComponent<EnemyStateMachine>()); 

[TO SAVE US BOTH TIME, PLEASE SKIP TO THE VERY LAST COMMENT. I FIXED NEARLY EVERYTHING BUT WHATEVER IS WRITTEN IN THE LAST COMMENT :sweat_smile:]

I’ll try find a way to work around with this, but I’ll wait for the weekend’s Update first, since I’m already half way through this implementation, and I’m a little confused myself as of how to fix my own problems

No worries, anything is better than nothing, but please don’t forget about it :smiley:

Regarding this solution, especially for the RespawnManager, I don’t think it’ll be possible, since they get deleted on death to conserve performance, before being added again when respawned. We will have to find another way around this one (I literally just discovered this problem about 5 minutes ago), or I may have to re-program it to fit in, but I genuinely have no idea how

Edit 1: AND… Now I’m dealing with something that’s driving me to pure insanity, and it’s a really pointless one too:

        private AIAggroGroupSwitcher aggroGroupSwitcher; // TEST 16/5/2024

        private void Awake()
        {
            // TEST
            timeKeeper = TimeKeeper.GetTimeKeeper();

            // TEST - 17/5/2024:
            playerAggroGroup = GameObject.Find("PlayerAggroGroup").GetComponent<AggroGroup>();
            aggroGroupSwitcher = GetComponentInChildren<AIAggroGroupSwitcher>(); // TEST 16/5/2024

            // Check if the Enemy has been restored first or not, prior to Respawning him (ensuring RestoreState(), which occurs first, works properly)
            if (!hasBeenRestored) Respawn();
        }

Now I’m not sure if that’s something to do with the fact that the enemies get instantiated when the game runs or not, but Awake is unable to accumulate ‘AggroGroupSwitcher’ although it’s attached to the character that gets instantiated when the game starts. Why is this the case?

I have also tried placing it in ‘Start()’, I figured it may be a speed problem, but that failed too…

The debugger in Respawner says it can’t find it for my test character, which is a big surprise for me

I need this for a new approach I’m testing, but I was not expecting something this silly to give me a major headache…


Edit 2: I fixed the problem above, but now I see where the core problem actually is… When the enemy dies, he gets permanently deleted from the game’s hierarchy, which means that when he returns to life in the game, he doesn’t remember what was going on (because… well… he’s re-instantiated from a fixed prefab), and obviously I can’t change the data of something that doesn’t exist in the game, which means I either:

  1. need to get the data changed in the prefab itself (is that even possible…?!), or:

  2. find a way to get him to never actually be deleted from the game. Instead, he can just get his renderer to vanish for some time so the data can stay around, or

  3. Save his data and restore it within the game itself, or

  4. (This is the most viable one) Create a saving system for ‘AIAggroGroupSwitcher.cs’, to contain ‘isInPlayerAggroGroup’, so that it can be saved and restored each time the enemy dies and respawns…

  5. Use a Predicate (and I’m starting to ask myself why am I even confusing myself so badly)

I don’t know how to do either to be honest. The only one I can probably take care of, is the renderer approach, but this one will probably get other systems, like the AI Fighting System, to fail, because they can still see him although he’s dead… and that’s where I need help :sweat_smile:

For the time being, this is what my code looks like:

AIAggroGroupSwitcher.cs:

using UnityEngine;
using RPG.States.Enemies;
using RPG.Respawnables;
using RPG.Dialogue;

namespace RPG.Combat {

public class AIAggroGroupSwitcher : MonoBehaviour
{
    // This script is placed on NPCs, and is essentially responsible for
    // allowing them to enter and exit the player's AggroGroup, through
    // dialogue:

    private AggroGroup playerAggroGroup;
    private AggroGroup currentAggroGroup;
    private AIConversant conversant;

    [SerializeField] private RPG.Dialogue.Dialogue NPCOutOfPlayerGroupInviteDialogue;
    [SerializeField] private RPG.Dialogue.Dialogue NPCInPlayerGroupKickOutDialogue;

    [SerializeField] private bool isInPlayerAggroGroup;

    public void Awake()
    {
        playerAggroGroup = GameObject.Find("PlayerAggroGroup").GetComponent<AggroGroup>();
        conversant = GetComponent<AIConversant>();
    }

    public void AddNPCToPlayerAggroGroup()
    {
        Debug.Log($"{gameObject.name} added to player AggroGroup");
        // This function essentially deletes the Enemy 
        // that calls it from his own AggroGroup, and adds him to
        // the Player's AggroGroup, and is meant to be called 
        // at the end of the dialogues that lead to that action:
        if (playerAggroGroup.GetGroupMembers().Contains(this.GetComponent<EnemyStateMachine>())) return; // if this script holder is part of the Player's AggroGroup, don't add him again

        // First, Delete him from his own AggroGroup
        this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().GetComponentInParent<AggroGroup>().RemoveFighterFromGroup(this.GetComponent<EnemyStateMachine>());
        // And now, add him to the player's AggroGroup
        playerAggroGroup.AddFighterToGroup(this.GetComponent<EnemyStateMachine>());
        // Next, update the state of 'currentAggroGroup', a variable to keep track of which team the NPC is on now
        currentAggroGroup = playerAggroGroup;
        // Finally, let 'RespawnManager.cs' know which AggroGroup the Script Holder is on, so it knows how to treat the situationa accordingly
        this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().SetAggroGroup(currentAggroGroup);
        // Swap the dialogue out for the one where the NPC can be potentially be kicked out of the Player's AggroGroup now:
        conversant.SetDialogue(NPCInPlayerGroupKickOutDialogue);
        // (FOR 'RespawnManager.cs': Extra variable, because without this one, the dialogue will be wrong when the enemy NPC respawns):
        SetIsInPlayerAggroGroup(true);
    }

    public void RemoveNPCFromPlayerAggroGroup()
    {
        Debug.Log($"{gameObject.name} kicked out of player AggroGroup");
        // Similar to how 'AddNPCToPlayerAggroGroup()' adds the script holder to
        // the player's AggroGroup, this function is supposed to delete the script
        // holder from the Player's AggroGroup:

        if (!playerAggroGroup.GetGroupMembers().Contains(this.GetComponent<EnemyStateMachine>())) return; // if this script holder is no longer part of the Player's AggroGroup, don't try deleting him again

        // First, Add the NPC back to his original Individual AggroGroup
        this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().GetComponentInParent<AggroGroup>().AddFighterToGroup(this.GetComponent<EnemyStateMachine>());
        // Remove the NPC from the Player's AggroGroup
        playerAggroGroup.RemoveFighterFromGroup(this.GetComponent<EnemyStateMachine>());
        // Update the current AggroGroup, for the RespawnManager
        currentAggroGroup = this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().GetComponentInParent<AggroGroup>();
        // let 'RespawnManager.cs' know which AggroGroup the Script holder is on, so it treats the situation accordingly
        this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().SetAggroGroup(currentAggroGroup);
        // Swap the dialogue out for the one to invite the NPC to the Player's AggroGroup
        conversant.SetDialogue(NPCOutOfPlayerGroupInviteDialogue);
        // (FOR 'RespawnManager.cs': Extra variable, because without this one, the dialogue will be wrong when the enemy NPC respawns):
        SetIsInPlayerAggroGroup(false);
    }

    public bool GetIsInPlayerAggroGroup() 
    {
        return isInPlayerAggroGroup;
    }

    public void SetIsInPlayerAggroGroup(bool isInPlayerAggroGroup) 
    {
        this.isInPlayerAggroGroup = isInPlayerAggroGroup;
        Debug.Log($"SetIsInPlayerAggroGroup set to: {isInPlayerAggroGroup}");
    }

    public Dialogue.Dialogue GetNPCOutOfPlayerGroupInviteDialogue()
    {
        return NPCOutOfPlayerGroupInviteDialogue;
    }

    public Dialogue.Dialogue GetNPCInPlayerGroupKickOutDialogue()
    {
        return NPCInPlayerGroupKickOutDialogue;
    }

}

}

RespawnManager.cs

using System.Collections.Generic;
using GameDevTV.Saving;
using RPG.Control;
using UnityEngine;
using RPG.Combat;
using Newtonsoft.Json.Linq;
using RPG.States.Enemies;
using System.Collections;
using System;
using RPG.Dialogue;

namespace RPG.Respawnables
{
    [RequireComponent(typeof(JSONSaveableEntity))]
    public class RespawnManager : MonoBehaviour, IJsonSaveable
    {
        [SerializeField] EnemyStateMachine spawnableEnemy;

        [SerializeField] private float hideTime;   // time before hiding our dead character
        [SerializeField] private float respawnTime;    // time before respawning our hidden dead character, as another alive, respawned character
        [SerializeField] PatrolPath patrolPath; // the path our character will follow, from his spawn point
        [SerializeField] AggroGroup aggroGroup; // aggrevated group of guards, based on wrong dialogue player has said
        [SerializeField] bool hasBeenRestored;  // checks if the enemy has been restored before fading in from the main menu, or a load scene, or not

        // private AIController spawnedEnemy; // changed to EnemyStateMachine type below
        // private EnemyStateMachine spawnedEnemy; // in-game instance of the enemy

        // TEST (TimeKeeper)
        private double destroyedTime;
        private TimeKeeper timeKeeper;

        // TEST - 17/5/2024:
        private AggroGroup playerAggroGroup;
        [SerializeField] private AIAggroGroupSwitcher aggroGroupSwitcher; // TEST 16/5/2024

        // --------------------------- NOTE: RestoreState() occurs BEFORE Start(), hence we need to change Start() to Awake() --------------
        private void Awake()
        {
            // TEST
            timeKeeper = TimeKeeper.GetTimeKeeper();

            // TEST - 17/5/2024:
            playerAggroGroup = GameObject.Find("PlayerAggroGroup").GetComponent<AggroGroup>();
            // Check if the Enemy has been restored first or not, prior to Respawning him (ensuring RestoreState(), which occurs first, works properly)
            if (!hasBeenRestored) Respawn();
        }
        // --------------------------------------------------------------------------------------------------------------------------------

        private void Respawn()
        {
            var spawnedEnemy = GetSpawnedEnemy();

            if (spawnedEnemy != null)
            {
                // If you found an enemy as a child of this script holder, delete his 'OnDeath' listener
                spawnedEnemy.Health.onDie.RemoveListener(OnDeath);
            }

            foreach (Transform child in transform)
            {
                // Start the Respawn by deleting any existing gameObjects
                Destroy(child.gameObject);
            }

            spawnedEnemy = Instantiate(spawnableEnemy, transform);

            // If the enemy has a weapon that's supposed to be in his hand, make him wear it:
            if (spawnedEnemy.GetComponent<Fighter>().GetCurrentWeaponConfig() != null)
            {
                WeaponConfig enemyWeaponConfig = spawnedEnemy.GetComponent<Fighter>().currentWeaponConfig;
                spawnedEnemy.GetComponent<Fighter>().AttachWeapon(enemyWeaponConfig);
            }

            // Get the spawned/respawned enemies' health, and listen for death notifications
            spawnedEnemy.Health.onDie.AddListener(OnDeath);

            if (patrolPath != null)
            {
                Debug.Log($"Assigning Patrol Path {patrolPath} to {spawnedEnemy.name}");
                spawnedEnemy.AssignPatrolPath(patrolPath);
                // set the enemy's initial hostility here instead of 'EnemyStateMachine.Start()', otherwise the override will mess with your head for days 
                // (I made that mistake, hence why I'm writing about it here):
                // if the spawned Enemy is part of the Player's AggroGroup, turn off his hostility on respawn against the Player:
                if (spawnedEnemy != null && spawnedEnemy.GetAggroGroup() != null && spawnedEnemy.GetAggroGroup().GetPlayer()) spawnedEnemy.SetHostile(false);
                else spawnedEnemy.SetHostile(spawnedEnemy.GetInitialHostility);
                spawnedEnemy.SwitchState(new EnemyIdleState(spawnedEnemy));
            }
            else
            {
                Debug.Log($"No Patrol Path to assign");
            }

            // For Dialogue-Powered NPCs, you need to find a way to get the proper conversation for their state, based on whether they're on your team or not:
            if (aggroGroupSwitcher == null) 
            {
                // For some reason, Awake can't get the AggroGroupSwitcher. 
                // So instead of dealing with the headache, just get it in 'Respawn()' itself:
                Debug.Log($"{GetComponentInChildren<EnemyStateMachine>().gameObject.name} aggroGroupSwitcher not found");
                aggroGroupSwitcher = GetComponentInChildren<AIAggroGroupSwitcher>();
                if (aggroGroupSwitcher != null) 
                {
                    Debug.Log($"aggroGroupSwitcher assigned to {GetComponentInChildren<EnemyStateMachine>().gameObject.name}");
                    if (aggroGroupSwitcher.GetIsInPlayerAggroGroup() == false)
                    {
                        // if you're not part of the Player's AggroGroup, this is where you go:
                        GetComponentInChildren<AIConversant>().SetDialogue(GetComponentInChildren<AIAggroGroupSwitcher>().GetNPCOutOfPlayerGroupInviteDialogue());
                        Debug.Log($"Dialogue is player group invite");
                    }
                    else
                    {
                        // if you're part of the Player's AggroGroup, this is where you go:
                        GetComponentInChildren<AIConversant>().SetDialogue(GetComponentInChildren<AIAggroGroupSwitcher>().GetNPCInPlayerGroupKickOutDialogue());
                        Debug.Log($"Dialogue is player group kick out");
                    }
                }
            }

            // --------------------------- Extra Functionality: Setting up Aggro Group + Adding Fighters ---------------
            if (aggroGroup != null)
            {
                aggroGroup.AddFighterToGroup(spawnedEnemy);

                // -------------------------------- TEST AREA ------------------------------------------------------------------------------------------------------------------------

                // get the AggroGroup on Respawn, so when the enemy returns to life, he can go through the list of allies, and if any of them are under attack, he can try fight with them
                spawnedEnemy.SetAggroGroup(spawnedEnemy.GetAggroGroup());

                if (spawnedEnemy.GetAggroGroup() != null)
                {
                    foreach (EnemyStateMachine allyEnemy in spawnedEnemy.GetAggroGroup().GetGroupMembers())
                    {
                        if (spawnedEnemy != null && spawnedEnemy.GetAggroGroup().GetPlayer()) spawnedEnemy.SetHostile(false);
                        // a friend of the NPC that just respawned is under attack, so that just-respawned NPC should go and fight for them:
                        else if (allyEnemy != null && allyEnemy != spawnedEnemy && allyEnemy.GetOpponent() != null && allyEnemy.GetOpponent().GetComponent<EnemyStateMachine>().GetAggroGroup() != spawnedEnemy.GetAggroGroup())
                        {
                            // aim for whoever is attacking your allies, and then break after finding the first one:
                            spawnedEnemy.SetOpponent(allyEnemy.GetOpponent());
                            spawnedEnemy.SetHasOpponent(true);
                            // Find a way to set the hostility of respawned player allies here to false:
                            spawnedEnemy.SetHostile(true, allyEnemy.GetOpponent());
                            Debug.Log($"{spawnedEnemy.gameObject.name} Respawned and is supposed to fight... SetHostile set to {spawnedEnemy.IsHostile}");
                            break; // once you found someone, you stick to them until either one of you is dead
                        }
                    }
                }

                // -------------------------------------- END OF TEST AREA -----------------------------------------------------------------------------------------------------------

                if (spawnedEnemy.TryGetComponent(out DialogueAggro dialogueAggro)) //aggrogroup is at this point valid
                {
                    dialogueAggro.SetAggroGroup(aggroGroup);
                }
            }
            // ---------------------------------------------------------------------------------------------------------
        }

        public void SetAggroGroup(AggroGroup aggroGroup) 
        {
            // This setter is used in 'AIAggroGroupSwitcher.cs', essentially to
            // let this Respawn Manager know which AggroGroup to load the Respawned
            // enemy to
            this.aggroGroup = aggroGroup;
        }

    private IEnumerator HideCharacter()
    {
        Debug.Log($"HideCharacter in progress");
        var spawnedEnemy = GetSpawnedEnemy();
        if (spawnedEnemy == null) yield break;
        spawnedEnemy.transform.SetParent(null);
        yield return new WaitForSecondsRealtime(hideTime);
        Destroy(spawnedEnemy.gameObject);
    }

    void OnDeath()
    {
        var spawnedEnemy = GetSpawnedEnemy();

        if (spawnedEnemy != null)
        {
            spawnedEnemy.Health.onDie.RemoveListener(OnDeath);
            StartCoroutine(HideCharacter());
            destroyedTime = timeKeeper.GetGlobalTime();
            StartCoroutine(WaitAndRespawn());
            if (aggroGroup != null)
            {
                aggroGroup.RemoveFighterFromGroup(spawnedEnemy);
            }
        }
    }

    private IEnumerator WaitAndRespawn()
    {
        var elapsedTime = (float)(timeKeeper.GetGlobalTime() - destroyedTime);
        yield return new WaitForSecondsRealtime(respawnTime - elapsedTime);
        Respawn();
    }

    private bool IsDead()
    {
        var spawnedEnemy = GetSpawnedEnemy();
        return spawnedEnemy == null || spawnedEnemy.Health.IsDead();
    }

    private EnemyStateMachine GetSpawnedEnemy()
    {
        return GetComponentInChildren<EnemyStateMachine>();
    }

    public JToken CaptureAsJToken()
    {
            JObject state = new JObject();
            IDictionary<string, JToken> stateDict = state;

            // TEST (Adding data to the JObject Dictionary):
            var isDead = IsDead();
            var data = new RespawnData(destroyedTime, isDead);
            stateDict["RespawnData"] = JToken.FromObject(data);

            // we only care about data of alive enemies
            if (!isDead)
            {
                var spawnedEnemy = GetSpawnedEnemy();
                foreach (IJsonSaveable JSONSaveable in spawnedEnemy.GetComponents<IJsonSaveable>())
                {
                    JToken token = JSONSaveable.CaptureAsJToken();
                    string component = JSONSaveable.GetType().ToString();
                    Debug.Log($"{name} Capture {component} = {token.ToString()}");
                    stateDict[component] = token;
                }
            }
            return state;
    }

        public void RestoreFromJToken(JToken s)
        {
            
            JObject state = s.ToObject<JObject>();
            IDictionary<string, JToken> stateDict = state;

            var data = default(RespawnData);
            if (stateDict.TryGetValue("RespawnData", out var dataToken))
            {
                data = dataToken.ToObject<RespawnData>();
            }

            var isDead = data.IsDead;
            destroyedTime = data.DestroyedTime;

            // Should be dead
            if (isDead && !IsDead())
            {
                Debug.Log("Should be dead, but isn't...");
                var spawnedEnemy = GetSpawnedEnemy();
                Debug.Log($"Listeners before: {spawnedEnemy.Health.onDie.GetPersistentEventCount()}");
                spawnedEnemy.Health.onDie.RemoveListener(OnDeath);
                Debug.Log($"Listeners after: {spawnedEnemy.Health.onDie.GetPersistentEventCount()}");
                Debug.Log($"Health Before: {spawnedEnemy.Health.GetHealthPoints()}");
                spawnedEnemy.Health.Kill();
                Debug.Log($"Health After: {spawnedEnemy.Health.GetHealthPoints()}");
                StartCoroutine(WaitAndRespawn());
                if (aggroGroup != null)
                {
                    aggroGroup.RemoveFighterFromGroup(spawnedEnemy);
                }
                StartCoroutine(HideCharacter());
                // HideCharacter();
                Debug.Log($"Spawned Enemy: {GetSpawnedEnemy()}");
            }
            else if (isDead && IsDead())
            {
                Debug.Log("Should be dead, and is indeed dead...");
                StopAllCoroutines();
                StartCoroutine(WaitAndRespawn());
                StartCoroutine(HideCharacter());
                // HideCharacter();
            }
            // Should be alive
            else if (!isDead && IsDead())
            {
                Debug.Log("Shouldn't be dead, but is dead...");
                Respawn();
                LoadEnemyState(stateDict);
            }
            else
            {
                Debug.Log("Shouldn't be dead, and isn't dead...");
                LoadEnemyState(stateDict);
            }
        }

        private void LoadEnemyState(IDictionary<string, JToken> stateDict)
        {
            var spawnedEnemy = GetSpawnedEnemy();
            foreach (IJsonSaveable jsonSaveable in spawnedEnemy.GetComponents<IJsonSaveable>())
            {
                string component = jsonSaveable.GetType().ToString();
                if (stateDict.ContainsKey(component))
                {
                    Debug.Log($"{name} Restore {component} => {stateDict[component].ToString()}");
                    jsonSaveable.RestoreFromJToken(stateDict[component]);
                }
            }
        }
    }

    [Serializable]
    public struct RespawnData
    {
        public bool IsDead;
        public double DestroyedTime;

        public RespawnData(double destroyedTime, bool isDead)
        {
            IsDead = isDead;
            DestroyedTime = destroyedTime;
        }
    }
}

and ‘AIConversant.cs’:

using UnityEngine;
using RPG.Attributes;
using RPG.Core;

namespace RPG.Dialogue {

    public class AIConversant : MonoBehaviour, ITarget
    {

        [SerializeField] Dialogue dialogue = null;
        [SerializeField] string conversantName;

        public string GetName()
        {
            return conversantName;
        }

        // Third Person Transition Implementation:
        public Dialogue GetDialogue()
        {
            return dialogue;
        }

        // This setter will set the dialogue, based on the state of the conversant.
        // (For now it's used to change the dialogue for NPCs that join and leave the player's
        // AggroGroup, so we can kick and invite them as we please)
        public void SetDialogue(Dialogue dialogue) 
        {
            this.dialogue = dialogue;
        }

        public bool IsValid()
        {
            if (dialogue == null) return false; // if there is no dialogue, NPC is not open for Talks
            if (TryGetComponent(out Health health) && health.IsDead()) return false;    // you can't talk to Dead men... LMAO
            return true;
        }

        public event System.Action<AIConversant> OnDeath;

        private Health health;

        // Race-Condition Bulletproof Solution to use events
        // to deal with the death of enemies,
        // and block the ability of talking to dead enemies, below:
        private Health Health
        {
            get
            {
                // if you can find health attached to this conversant
                // get it (so we can subscribe to it in 'Awake()'):
                if (!health) health = GetComponent<Health>();
                return health;
            }
        }

        private void Awake()
        {
            Health.onDie.AddListener(() =>
            {
                OnDeath?.Invoke(this);
            });
        }

    }

}

The final solution, number 6, that I can temporarily use, is to move ‘IsInPlayerAggroGroup’ from ‘AIAggroGroupSwitcher.cs’ to ‘RespawnManager.cs’, so that it acts as the perfect spot to hold this kind of information I believe (I have to head off for prayer in less than an hour, so I’ll update this comment when I return), since ‘RespawnManager.cs’ never quits the scene once it’s there

And the best of all, you get to save and load it safely as you please

[SKIP TO THE VERY LAST COMMENT PLEASE]

[SOLVED: I forgot to set my EnemyStateMachine’s ‘AggroGroup’ variable, through the ‘AIAggroGroupSwitcher’ script, when my enemy quits the player’s AggroGroup, which was causing me the major headache that I was dealing with. Feel free to check the solution at the very end to get what I mean]


OK so… A little bit of an Update

Whilst my approach for getting the dialogue in Respawn Manager is clearly an indicator that I haven’t thought of this one for the long term, because I can foresee a few future issues that I probably haven’t thought much of yet, but it seems to be working (for the time being, when he respawns, and how the dialogue corresponds to the situation the character was in, since it never leaves the game scene. I am yet to test this on the long run for any potential weird bugs)

HOWEVER, when I kick the NPC out of my team, it introduces a NullReferenceException each time I hit him, which leads to something I created when I was developing my AI Battle System, a ‘foreach’ loop basically, in ‘EnemyStateMachine.OnTakenHit’:

                    // This foreach loop is for enemies who are part of the AggroGroup of the enemy that just got hit:
                    foreach (EnemyStateMachine enemy in GetAggroGroup().GetGroupMembers().Where(enemy => enemy != null))
                    {
                        // if two enemies are fighting it out, within the same AggroGroup team,
                        // and one of them is instigated by the player, this enemy will automatically
                        // cut the fight out and focus on fighting the players' AggroGroup, but the
                        // LastAttacker will have to focus on the Nearest Unassigned Enemy he can find from
                        // the Player's AggroGroup
                        // (for prototyping purposes, you can place the following block in the foreach above
                        // but for now having it outside somehow avoids an arrow-through-dead-enemy problem):
                        if (LastAttacker != null && instigator != LastAttacker && LastAttacker.GetComponent<EnemyStateMachine>().GetAggroGroup() == GetAggroGroup())
                        {
                            Debug.Log("Player hit a victim, who was fighting an Ally. Last Attacker re-routed to fight a friend of the player");
                            EnemyStateMachine lastAttackerTarget = LastAttacker.GetComponent<EnemyStateMachine>().GetNearestUnassignedEnemyInEnemyAggroGroup(LastAttacker.GetComponent<EnemyStateMachine>(), instigator.GetComponent<PlayerStateMachine>().GetAggroGroup());
                            if (lastAttackerTarget == null) Debug.Log($"lastAttackerTarget is null");
                            if (lastAttackerTarget != null)
                            {
                                Debug.Log($"lastAttackerTarget is not null");
                                this.LastAttacker.GetComponent<EnemyStateMachine>().SetHostile(true, lastAttackerTarget.gameObject);
                                this.LastAttacker.GetComponent<EnemyStateMachine>().SetHasOpponent(true);
                                this.LastAttacker.GetComponent<EnemyStateMachine>().SetOpponent(lastAttackerTarget.gameObject);
                            }
                        }
                    }

The error comes from the ‘foreach’ line at the top of this block

I’m not sure exactly what the source of the bug is, but… I know it happens when this function is triggered, from my ‘AIAggroGroupSwitcher.cs’ script:

    public void RemoveNPCFromPlayerAggroGroup()
    {
        Debug.Log($"{gameObject.name} kicked out of player AggroGroup");

        // Debug.Log($"{gameObject.name} kicked out of player AggroGroup");
        // Similar to how 'AddNPCToPlayerAggroGroup()' adds the script holder to
        // the player's AggroGroup, this function is supposed to delete the script
        // holder from the Player's AggroGroup:

        if (!playerAggroGroup.GetGroupMembers().Contains(this.GetComponent<EnemyStateMachine>())) return; // if this script holder is no longer part of the Player's AggroGroup, don't try deleting him again

        // First, Add the NPC back to his original Individual AggroGroup:
        this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().GetComponentInParent<AggroGroup>().AddFighterToGroup(this.GetComponent<EnemyStateMachine>());
        // Remove the NPC from the Player's AggroGroup:
        playerAggroGroup.RemoveFighterFromGroup(this.GetComponent<EnemyStateMachine>());
        // Set the RespawnManager's Dialogue accordingly:
        enemyRespawnManager.SetCurrentConversation(GetNPCOutOfPlayerGroupInviteDialogue());
        // Switch the AggroGroup of the enemy holding this script, back to original AggroGroup:
        enemyRespawnManager.SetAggroGroup(GetComponentInParent<AggroGroup>());
        // Set the AIConversant's Dialogue accordingly (this is the core value that determines who holds the conversation):
        enemyAIConversant.SetDialogue(GetNPCOutOfPlayerGroupInviteDialogue());
    }

The only way to currently fix it, is to invite him back to the group, and I have no idea where it’s coming from…

I suspect the invitation function may also be responsible for something, because when the game starts, before he’s even invited to the group, all seems to be working just fine, so I’ll leave the invitation function here as well:

    public void AddNPCToPlayerAggroGroup()
    {
        Debug.Log($"{gameObject.name} added to player AggroGroup");
        // This function essentially deletes the Enemy 
        // that calls it from his own AggroGroup, and adds him to
        // the Player's AggroGroup, and is meant to be called 
        // at the end of the dialogues that lead to that action:
        if (playerAggroGroup.GetGroupMembers().Contains(this.GetComponent<EnemyStateMachine>())) return; // if this script holder is part of the Player's AggroGroup, don't add him again

        // First, Delete him from his own AggroGroup:
        this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().GetComponentInParent<AggroGroup>().RemoveFighterFromGroup(this.GetComponent<EnemyStateMachine>());
        // And now, add him to the player's AggroGroup:
        playerAggroGroup.AddFighterToGroup(this.GetComponent<EnemyStateMachine>());
        // Set the RespawnManager's Dialogue accordingly:
        enemyRespawnManager.SetCurrentConversation(GetNPCInPlayerGroupKickOutDialogue());
        // Switch the AggroGroup of the enemy holding this script, to the Player's AggroGroup:
        enemyRespawnManager.SetAggroGroup(playerAggroGroup);
        // Set the AIConversant's Dialogue accordingly (this is the core value that determines who holds the conversation):
        enemyAIConversant.SetDialogue(GetNPCInPlayerGroupKickOutDialogue());
    }

Can you please have a look and see if you can find anything suspicious? The debugger isn’t helping much here… :sweat_smile:

Another thing to mention, if it helps, is that the bug lasts until he is killed and reset on Respawn, so there’s a VERY VERY VERY UNIQUE Time window for this bug to occur

(if we can fix this, then all we have to do is save a single variable (please help… it’s a ‘Dialogue’ class type variable that needs saving, in ‘RespawnManager.cs’, called ‘currentConversation’), and hopefully call this system, for now at least, a day)

Edit 1: For some reason, ‘LastAttacker’ is not being setup, hence the failure within the ‘foreach’ loop. I’ll leave my latest ‘SetLastAttacker()’ function below as well, in case it helps (P.S: If it looks a little wild, it’s because of a ‘Mercy Factor’, something I created to get your allies to ignore the player’s accident hits during combat, has taken it’s toll in this function. You can honestly just ignore that big if statement down there):

    /// <summary>
    /// Who was the Last Attacker against this Enemy?
    /// </summary>
    /// <param name="instigator"></param>
    public void SetLastAttacker(GameObject instigator) 
    {
        // In the rare cases where you hit yourself, you can't set yourself as the LastAttacker:
        // if (instigator == gameObject) return;
        // if (instigator == null) LastAttacker = null; // if the instigator is nullified, give the script holder no goal to go towards (essentially sending him back to Patrolling State)
        
        // THE MERCY FACTOR, in 'EnemyStateMachine.OnTakenHit()', Essentially the solution 
        // to get my player's Allies to forgive my player if he hits them 'x' 
        // (3 in our case) times, before making him the target, introduces a glitch where 
        // the LastAttacker variable is suddenly nullified, 
        // when the player becomes the target of his allies, due to too many mistakes. 
        // The line below solves that problem (without this line, if one of your allies strikes
        // the other one, and they are on the same team, and the receiver is aiming at you, 
        // the receiver will keep focusing on you, introducing the problem. This is the hard-coded
        // solution):
        if (LastAttacker == null && instigator != null) {LastAttacker = instigator; return;}

        // if whoever the enemy was aiming for is dead, get them off the 'onDie' event listener:
        if (LastAttacker != null) 
        {
            LastAttacker.GetComponent<Health>().onDie.RemoveListener(ClearLastAttacker);
        }

            // if the last attacker is not dead, add them to the list, and then prepare them to be deleted off the list when they die:
            // LastAttacker = instigator; // (this line is in the 'else' statement of the block below)

            // (TEST - 11/5/2024):
            // Instead of automatically setting the Last Attacker as the instigator,
            // we have a special case need. If you were a teammate of the player, 
            // and the player hit you, and you were initially fighting someone else from 
            // a different AggroGroup, focus on the aggroGroup of whoever you're fighting, 
            // as it was probably an accident hit from the player
            // (P.S: anything more than 'maximumNumberOfAccidents' is no longer an accident hit, it's
            // on purpose, so you go wild on the player now):
            if (this.GetAggroGroup() != null && this.GetAggroGroup().GetPlayer() != null && this.GetOpponent() != null)
            {
                Debug.Log($"{this.gameObject.name} is part of the player's AggroGroup, and has an enemy");
                EnemyStateMachine opponentEnemy = this.GetOpponent().GetComponent<EnemyStateMachine>();
                if (opponentEnemy != null && opponentEnemy.GetAggroGroup() != this.GetAggroGroup())
                {
                    Debug.Log($"SetLastAttacker's {this.gameObject.name} will ignore the player's attack and focus on the other team");
                    EnemyStateMachine attackerBeforePlayer = opponentEnemy.GetComponent<EnemyStateMachine>();
                    if (attackerBeforePlayer != null)
                    {
                        Debug.Log($"{this.gameObject.name} SetLastAttacker's attacker before player is: {attackerBeforePlayer.gameObject.name}");
                        // EnemyStateMachine nearestEnemy = GetClosestEnemyInEnemyAggroGroup(this, attackerBeforePlayer.GetComponent<EnemyStateMachine>().GetAggroGroup());
                        EnemyStateMachine nearestEnemy = attackerBeforePlayer;
                        if (nearestEnemy != null)
                        {
                            // if you don't want the 'AccidentHitCounter' delete the if-else statement
                            // below, and only keep the 'LastAttacker = nearestEnemy.gameObject' line
                            if (GetAccidentHitCounter() >= maximumNumberOfAccidents)
                            {
                                Debug.Log($"{this.gameObject.name}  enough buddy, you'rsays: OK That'se under attack...!");
                                LastAttacker = instigator;
                                ResetAccidentHitCounter(); // the resetting of the 'AccidentHitCounter' is done here, to avoid the glitch of hunting the player down twice
                            }
                            else
                            {
                                Debug.Log($"{this.gameObject.name} says: Buddy, watch it...!");
                                LastAttacker = nearestEnemy.gameObject;
                                // Incrementing the 'AccidentHitCounter' is done in the 'OnTakenHit()' 
                                // function, to avoid doing it twice here (since 'SetLastAttacker()'
                                // is called in both 'Health.onDie' in 'Start()', and in 
                                // various functions in this script, we run the risk of calling it 
                                // twice by accident. To avoid that, we did the incrementing in 
                                // 'OnTakenHit()')
                            }
                        }
                    }
                }
            }
            else
            {
                // If you got to this 'else' statement, it means you were either not a part of
                // the Player's AggroGroup, or you were... but you weren't part of a fight with someone
                // else. This means that the player was just bothering you, so now you can safely fight
                // him to death, unless triggered by someone else:
                Debug.Log($"Natural ending for 'LastAttacker = instigator'");
                LastAttacker = instigator;
            }

        if (LastAttacker != null) 
        {
            Debug.Log($"LastAttacker against {this.gameObject.name} is: {LastAttacker.gameObject.name}");
            LastAttacker.GetComponent<Health>().onDie.AddListener(ClearLastAttacker);
        }
    }

[SOLUTION]:

Add this line at the end of ‘RemoveNPCFromPlayerAggroGroup()’:

        // Set the 'AggroGroup' within the 'EnemyStateMachine' as well, otherwise you're getting a NullReferenceException:
        GetComponent<EnemyStateMachine>().SetAggroGroup(GetComponentInParent<RespawnManager>().GetComponentInParent<AggroGroup>());

and this one at the end of ‘AddNPCToPlayerAggroGroup()’:

        // Set the 'AggroGroup' within the 'EnemyStateMachine' as well, otherwise you're getting a NullReferenceException:
        GetComponent<EnemyStateMachine>().SetAggroGroup(playerAggroGroup);

Alright so… now I have a review request, and a request to develop something. Let’s start with the request to develop something:

[REQUEST]

So, the way I went around getting the correct dialogue, regardless of whether the NPC was killed (i.e: Permanently deleted from the game) or not, was to store the value in ‘RespawnManager.cs’ instead of on the NPC, since the ‘RespawnManager.cs’ is always going to be there in the game scene, whereas the enemy will not (to conserve performance), and we can update that accordingly, through interactions with the NPC.

My request, is to add the ‘Dialogue.Dialogue currentConversation’ below:

        [Header("Conversation Changers, when an NPC dies:")]
        [Tooltip("Is this enemy part of the player's AggroGroup? Based on this AUTOMATICALLY CHANGING VALUE, Set the dialogue in 'AIConversant.cs' accordingly")]
        [SerializeField] private Dialogue.Dialogue currentConversation;

into the following saving system, that me and @bixarrio have developed a while ago, in ‘RespawnManager.cs’:

        public JToken CaptureAsJToken()
        {
            JObject state = new JObject();
            IDictionary<string, JToken> stateDict = state;

            // TEST (Adding data to the JObject Dictionary):
            var isDead = IsDead();
            var data = new RespawnData(destroyedTime, isDead);
            stateDict["RespawnData"] = JToken.FromObject(data);

            // we only care about data of alive enemies
            if (!isDead)
            {
                var spawnedEnemy = GetSpawnedEnemy();
                foreach (IJsonSaveable JSONSaveable in spawnedEnemy.GetComponents<IJsonSaveable>())
                {
                    JToken token = JSONSaveable.CaptureAsJToken();
                    string component = JSONSaveable.GetType().ToString();
                    Debug.Log($"{name} Capture {component} = {token.ToString()}");
                    stateDict[component] = token;
                }
            }
            return state;
        }

        public void RestoreFromJToken(JToken s)
        {
            
            JObject state = s.ToObject<JObject>();
            IDictionary<string, JToken> stateDict = state;

            var data = default(RespawnData);
            if (stateDict.TryGetValue("RespawnData", out var dataToken))
            {
                data = dataToken.ToObject<RespawnData>();
            }

            var isDead = data.IsDead;
            destroyedTime = data.DestroyedTime;

            // Should be dead
            if (isDead && !IsDead())
            {
                Debug.Log("Should be dead, but isn't...");
                var spawnedEnemy = GetSpawnedEnemy();
                Debug.Log($"Listeners before: {spawnedEnemy.Health.onDie.GetPersistentEventCount()}");
                spawnedEnemy.Health.onDie.RemoveListener(OnDeath);
                Debug.Log($"Listeners after: {spawnedEnemy.Health.onDie.GetPersistentEventCount()}");
                Debug.Log($"Health Before: {spawnedEnemy.Health.GetHealthPoints()}");
                spawnedEnemy.Health.Kill();
                Debug.Log($"Health After: {spawnedEnemy.Health.GetHealthPoints()}");
                StartCoroutine(WaitAndRespawn());
                if (aggroGroup != null)
                {
                    aggroGroup.RemoveFighterFromGroup(spawnedEnemy);
                }
                StartCoroutine(HideCharacter());
                // HideCharacter();
                Debug.Log($"Spawned Enemy: {GetSpawnedEnemy()}");
            }
            else if (isDead && IsDead())
            {
                Debug.Log("Should be dead, and is indeed dead...");
                StopAllCoroutines();
                StartCoroutine(WaitAndRespawn());
                StartCoroutine(HideCharacter());
                // HideCharacter();
            }
            // Should be alive
            else if (!isDead && IsDead())
            {
                Debug.Log("Shouldn't be dead, but is dead...");
                Respawn();
                LoadEnemyState(stateDict);
            }
            else
            {
                Debug.Log("Shouldn't be dead, and isn't dead...");
                LoadEnemyState(stateDict);
            }
        }

as this is the variable that’s responsible for who goes where, when the enemy child respawns. I wanted to do it myself, but frankly speaking, I’m not very familiar with Saving Systems just yet. I would really appreciate your help with that part :smiley:

and:


  1. Review request:

as this is a bit of a sloppy area to be in, because it involves having extra dialogue switches, this part of ‘RespawnManager.Respawn()’ should be enough to deal with both cases of having specific dialogue for respawning (when we want to switch their teams), and when you want to leave ‘currentConversation’ empty (i.e: no dialogue changes based on teams and what not, essentially when one dialogue in ‘AIConversant’ is enough), am I correct?:

            // Get the Dialogue that the NPC should have here as well, and assign it to 'AIConversant.cs', the Child GameObject of this script holder:
            if (currentConversation == null && GetComponentInChildren<AIConversant>() != null && GetComponentInChildren<AIConversant>().GetDialogue() != null)
            {
                // when the game starts, unless the saving system is holding a value, 
                // 'currentConversation' is typically still null. In this case, we want
                // whatever we assigned to 'AIConversant.GetDialogue()' to be assigned to
                // 'currentConversation', so we have a reference point for when the enemy
                // respawns, so he knows who he is with:
                Debug.Log($"{GetComponentInChildren<EnemyStateMachine>().gameObject.name} has no current conversation on Respawning");
                currentConversation = GetComponentInChildren<AIConversant>().GetDialogue();
            }
            else if (currentConversation != null && GetComponentInChildren<AIConversant>() != null)
            {
                Debug.Log($"{GetComponentInChildren<EnemyStateMachine>().gameObject.name} got AI Conversant updated to 'currentConversant'");
                GetComponentInChildren<AIConversant>().SetDialogue(GetCurrentConversation());
            }

Edit 1: I tried integrating ‘currentConversation’ into the saving system, as follows:

in ‘CaptureAsJToken()’:

            // TEST - 19/5/2024 - Saving currentConversation:
            if (currentConversation != null) 
            {
                stateDict["currentConversation"] = JToken.FromObject(currentConversation);
            }

and in ‘RestoreFromJToken()’:

            // TEST - 19/5/2024 - Restore currentConversation:
            if (stateDict.TryGetValue("currentConversation", out var conversationToken)) 
            {
                currentConversation = conversationToken.ToObject<Dialogue.Dialogue>();
                GetComponentInChildren<AIConversant>().SetDialogue(currentConversation);
            }

Now, although this may look just fine, whenever I try to get into conversation, I get this error (when I return to my game):

ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection.
Parameter name: index
System.Collections.Generic.List`1[T].get_Item (System.Int32 index) (at <88e4733ac7bc4ae1b496735e6b83bbd3>:0)
RPG.Dialogue.Dialogue.GetRootNode () (at Assets/Project Backup/Scripts/Dialogue/Dialogue.cs:77)
RPG.Dialogue.PlayerConversant.StartDialogue (RPG.Dialogue.AIConversant newConversant, RPG.Dialogue.Dialogue newDialogue) (at Assets/Project Backup/Scripts/Dialogue/PlayerConversant.cs:36)
RPG.States.Player.PlayerConversantState.Enter () (at Assets/Project Backup/Scripts/State Machines/Player/PlayerConversantState.cs:28)
RPG.States.StateMachine.SwitchState (RPG.States.State newState) (at Assets/Project Backup/Scripts/State Machines/StateMachine.cs:13)
RPG.States.Player.PlayerFacingState.Tick (System.Single deltaTime) (at Assets/Project Backup/Scripts/State Machines/Player/PlayerFacingState.cs:42)
RPG.States.StateMachine.Update () (at Assets/Project Backup/Scripts/State Machines/StateMachine.cs:18)

which leads to this line, in ‘Dialogue.cs’:

return nodes[0];

of this function:

        public DialogueNode GetRootNode() {

            return nodes[0];

        }

(I did some further testing, and I can confirm 100% that this is relevant to my Saving and Retrieving of the ‘currentConversation’ in ‘RespawnManager.cs’…!)

Edit 2: After a little bit of further research, I figured it’s because the saving system will save the node count as zero for some reason, which means that the nodes never got saved to begin with (and that’s for all dialogues I guess…). Time to fix this problem I guess (I HAVE ZERO IDEA HOW, PLEASE SEND HELP… All I seek is a solution to save ‘currentConversation’ in ‘RespawnManager.cs’, with all of the nodes in that dialogue conversation, and get the nodes appropriately, but I truly am clueless)

Dialogues aren’t normally stored in the Resources, so you’d need to do something like what we do with Quests, capturing the name of the Dialogue, and then have a GetByName() function like Quest.GetByName() that you can retrieve the Dialogue in Restore.

Remember that the Dialogues would then have to move to a Resources Folder or they won’t be retrievable.

Privacy & Terms