Conversations and Dialogue Triggers

[SKIP TO THE LAST COMMENT]

Funnily enough, I was just watching the video in the courses relevant to the Quest Saving System, trying to understand what’s going on there, once again… Let’s just say I have a big problem understanding what’s going on, and more likely than not I’ll watch it again to try grasp the idea better (I may have a problem with understanding events, but I have an even bigger problem with saving systems. I guess it all depends on what you practice in the end, and I was going to eventually bump into this problem anyway :sweat_smile:)

does doing that automatically retrieve the nodes, the children, their IDs and all of the relevant data as well?

[AGAIN, PLEASE SKIP TO THE LAST COMMENT]

OK so… I managed to capture and restore the correct data for ‘currentConversation’, as follows:

in ‘CaptureAsJToken()’:

            // Capture the Current Conversation (so our allies who are on our team can be kicked out when we return to the game,
            // and foreigners can join our team as well when necessary):
            stateDict["CurrentConversation"] = currentConversation != null ? currentConversation.name : null;

and in ‘RestoreFromJToken()’:

            // Restore the Current Conversation (so our allies who are on our team can be kicked out when we return to the game,
            // and foreigners can join our team as well when necessary):
            if (stateDict.TryGetValue("CurrentConversation", out var conversationToken))
            {
                var conversationName = conversationToken.ToObject<string>();
                currentConversation = !string.IsNullOrEmpty(conversationName) ? Dialogue.Dialogue.GetByName(conversationName) : null;
                GetComponentInChildren<AIConversant>().SetDialogue(currentConversation);
            }

and this is all done in ‘RespawnManager.cs’ (and I moved all my dialogues to a ‘Resources’ folder to get the ‘RestoreFromJToken’ name getter to work)

The problem I currently am facing is, if I include the ‘GetComponentInChildren().SetDialogue(currentConversation);’ line (my attempt at correctly setting the conversation up in the end), the screen goes black (which means that the fading back in failed, usually because either the restoring failed, or was stopped by something), and I have my suspicions that it’s because the AIConversant.cs script holder has not been instantiated yet, as the ‘AIConversant’ script holder is the enemy himself, which is a child of the “RespawnManager.cs” script holder, and it’s dynamically instantiated in the game

What’s the best way to go around this problem?

Edit 1: I tried doing this in ‘AIConversant.Start()’, which is attached to the NPC Enemy (a child of the ‘RespawnManager’, and a grand child of ‘AggroGroup’):

        private void Start() 
        {
            if (GetComponentInParent<RespawnManager>().GetCurrentConversation() != null) 
            {
                dialogue = GetComponentInParent<RespawnManager>().GetCurrentConversation();
            }
        }

And whilst it did work for a mere second, eventually I realized where the major problem actually is.

At this point in time, I have learned the hard way that whatever the ‘Start()’ function sets up, it stays permanently there, regardless of any other overriding attempts through code down the line. It was the “big headache” for me when initially developing the AI Fighting System, which cost me 4 days of major confusion, and now it’s trying to do the same here… (The reason this won’t work here, is because the dialogue is dynamic for this special character, and start seems to be forcing a static solution here)

Any better solution ideas?


[ANYTHING BELOW HERE IS JUST RANT. FEEL FREE TO IGNORE]

Edit 2: I’ll test something out. For now, here’s part of my ‘Respawn’ as a placeholder:

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();
            }

And a little bit of an update… eliminating the ‘currentConversation’ accumulation out of the ‘Respawn()’ and doing it in ‘RestoreFromJToken()’ gets the right result, and for what it’s supposed to do it doesn’t seem to be doing a bad job at all… BUT IT CAN NEVER BE UPDATED AFTER THAT FROM ANY CALL IN ‘AIAggroGroupSwitcher.cs’ AND I HAVE NO IDEA WHY

and my ‘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 RespawnManager enemyRespawnManager;
    private AIConversant enemyAIConversant;

    [Tooltip("This is the conversation you would get if you want to invite an NPC to your AggroGroup (used in 'AIConversant.cs', setup in 'RespawnManager.cs')")]
    [SerializeField] private Dialogue.Dialogue NPCOutOfPlayerGroupInviteDialogue;
    [Tooltip("This is the conversation you would get if you want to kick out an NPC ally from your AggroGroup (used in 'AIConversant.cs', setup in 'RespawnManager.cs')")]
    [SerializeField] private Dialogue.Dialogue NPCInPlayerGroupKickoutDialogue;

    private void Awake()
    {
        playerAggroGroup = GameObject.Find("PlayerAggroGroup").GetComponent<AggroGroup>();
        // I'm getting the data here so data transfer can happen when Respawn is happening:
        Debug.Log($"{gameObject.name} aggroGroupSwitcher activated");
        enemyRespawnManager = GetComponentInParent<RespawnManager>();
        enemyAIConversant = GetComponent<AIConversant>();
    }

    public void AddNPCToPlayerAggroGroup()
    {
        // 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());
        // Set the 'AggroGroup' within the 'EnemyStateMachine' as well, otherwise you're getting a NullReferenceException:
        GetComponent<EnemyStateMachine>().SetAggroGroup(playerAggroGroup);
        Debug.Log($"{gameObject.name} added to player AggroGroup");
    }

    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>());
        // 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());
        // Set the 'AggroGroup' within the 'EnemyStateMachine' as well, otherwise you're getting a NullReferenceException:
        GetComponent<EnemyStateMachine>().SetAggroGroup(GetComponentInParent<RespawnManager>().GetComponentInParent<AggroGroup>());
        Debug.Log($"{gameObject.name} kicked out of player AggroGroup");
    }

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

    public void SetNPCOutOfPlayerGroupInviteDialogue(Dialogue.Dialogue dialogue) 
    {
        this.NPCOutOfPlayerGroupInviteDialogue = dialogue;
    }

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

    public void SetNPCInPlayerGroupKickOutDialogue(Dialogue.Dialogue dialogue) 
    {
        this.NPCInPlayerGroupKickoutDialogue = dialogue;
    }

}

}

Just in case I break something

AND MY ‘Capture’ and ‘Restore’ functions in ‘RespawnManager.cs’, before I break things up:

        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);

            // Capture the Current Conversation (so our allies who are on our team can be kicked out when we return to the game,
            // and foreigners can join our team as well when necessary):
            stateDict["CurrentConversation"] = currentConversation != null ? currentConversation.name : null;

            // 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;

            // Restore the Current Conversation (so our allies who are on our team can be kicked out when we return to the game,
            // and foreigners can join our team as well when necessary):
            if (stateDict.TryGetValue("CurrentConversation", out var conversationToken))
            {
                var conversationName = conversationToken.ToObject<string>();
                currentConversation = !string.IsNullOrEmpty(conversationName) ? Dialogue.Dialogue.GetByName(conversationName) : null;
                if (GetComponentInChildren<AIConversant>() != null) 
                {
                    Debug.Log($"Updating 'AIConversant.cs' in children");
                    GetComponentInChildren<AIConversant>().SetDialogue(currentConversation);
                }
            }

            // Should be dead, but isn't 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()) // Should be dead, and is dead
            {
                Debug.Log("Should be dead, and is indeed dead...");
                StopAllCoroutines();
                StartCoroutine(WaitAndRespawn());
                StartCoroutine(HideCharacter());
                // HideCharacter();
            }
            // Should be alive, but is dead
            else if (!isDead && IsDead())
            {
                Debug.Log("Shouldn't be dead, but is dead...");
                Respawn();
                LoadEnemyState(stateDict);
            }
            else // Should be alive, and is alive
            {
                Debug.Log("Shouldn't be dead, and isn't dead...");
                LoadEnemyState(stateDict);
            }
        }

OK so… I found out where the problem above is coming from, and it’s from this line:

        // The following line is correct, but for the system to fully work as expected, the Saving and Restoring System for 'AggroGroup.cs' must be developed!
        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
        Debug.Log($"{gameObject.name} kicked out of player AggroGroup - 2");

It’s a safety check I created in ‘AIAggroGroupSwitcher.cs’ in both the adder and remover of the NPC from the player’s AggroGroup, which was blocking the entire thing from proceeding

and the only natural solution to solve this problem, moving forward, is the inevitable fact that now I have to create a saving and restoring system for my ‘AggroGroup.cs’ script, essentially saving and restoring 4 different variables:

  1. A list of ‘enemies’. This is the most important one
  2. A boolean called ‘groupHatesPlayer’, something to aggrevate the entire team if the player touches one of them (I don’t even know why this is here anymore, considering I replaced it in ‘OnTakenHit’, but… yeah, let’s keep it for now)
  3. ‘resetIfPlayerDies’, to reset everyone when the player dies (again, no clue why it’s here, as ‘PlayerStateMachine.OnDie’ and ‘Respawner.ResetEnemies’ both overtake that job)
  4. ‘player’ of type ‘PlayerStateMachine.cs’, so we know which group has the player (it’s my way of identifying the player’s AggroGroup)

Whenever you see this, Please help me code it @Brian_Trotter (because I genuinely have no clue with this one…)

I tried copy pasting, and then fine-tuning the ‘QuestStatus.cs’ attempt, but the compiler complained that I dont have a ‘CaptureAsJToken()’ function in my ‘EnemyStateMachine.cs’ script, so I assumed I must’ve missed out something

This is the absolutely final step for this topic, then I’ll move on to something else

Edit 1: I asked ChatGPT for a little bit of help with this one, because I truly am clueless on how to code a Saving System, and here’s what it came up with (which didn’t work, by the way, but it’s a starting point), so as to reduce the amount of time we spend fixing this problem:

        public JToken CaptureAsJToken()
        {
            var state = new JObject();

            // Capture enemies as their unique IDs (Instance IDs):
            JArray enemiesArray = new JArray();
            foreach (var enemy in enemies) 
            {
                if (enemy != null) 
                {
                    enemiesArray.Add(enemy.GetInstanceID());
                }
            }

            state["enemies"] = enemiesArray;
            state["groupHatesPlayer"] = groupHatesPlayer;
            state["resetIfPlayerDies"] = resetIfPlayerDies;

            return state;
        }

        public void RestoreFromJToken(JToken state)
        {
            // Restore enemies from their Unique IDs (Instance IDs):
            var enemiesArray = state["enemies"].ToObject<List<int>>();
            enemies.Clear();
            foreach (var id in enemiesArray) 
            {
                var enemy = FindObjectsOfType<EnemyStateMachine>().FirstOrDefault(enemy => enemy.GetInstanceID() == id);
                if (enemy != null) 
                {
                    enemies.Add(enemy);
                }
            }

            groupHatesPlayer = state["groupHatesPlayer"].ToObject<bool>();
            resetIfPlayerDies = state["resetIfPlayerDies"].ToObject<bool>();
        }

That’s how it decided to save my ‘AggroGroup’ list of enemies, leading to the final script to be as follows:

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using Unity.VisualScripting;
using RPG.Attributes;
using RPG.States.Enemies;
using RPG.States.Player;
using GameDevTV.Saving;
using Newtonsoft.Json.Linq;

namespace RPG.Combat
{

    [RequireComponent(typeof(JSONSaveableEntity))]
    public class AggroGroup : MonoBehaviour, IJsonSaveable
    {
        [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
        }

        public JToken CaptureAsJToken()
        {
            var state = new JObject();

            // Capture enemies as their unique IDs (Instance IDs):
            JArray enemiesArray = new JArray();
            foreach (var enemy in enemies) 
            {
                if (enemy != null) 
                {
                    enemiesArray.Add(enemy.GetInstanceID());
                }
            }

            state["enemies"] = enemiesArray;
            state["groupHatesPlayer"] = groupHatesPlayer;
            state["resetIfPlayerDies"] = resetIfPlayerDies;

            return state;
        }

        public void RestoreFromJToken(JToken state)
        {
            // Restore enemies from their Unique IDs (Instance IDs):
            var enemiesArray = state["enemies"].ToObject<List<int>>();
            enemies.Clear();
            foreach (var id in enemiesArray) 
            {
                var enemy = FindObjectsOfType<EnemyStateMachine>().FirstOrDefault(enemy => enemy.GetInstanceID() == id);
                if (enemy != null) 
                {
                    enemies.Add(enemy);
                }
            }

            groupHatesPlayer = state["groupHatesPlayer"].ToObject<bool>();
            resetIfPlayerDies = state["resetIfPlayerDies"].ToObject<bool>();
        }
    }
}

it did exclude the PlayerStateMachine variable called ‘player’ though. It would be amazing if we integrate that, so the game knows the player’s team

QuestStatus was a bit of a unique thing because it’s not truly an ISaveable, (or even a Mono or SO), so we used a constructor instead. We really want to be capturing the current dialogue in AIConversant.

public JToken CaptureAsToken()
{
    if(currentConversation!=null)
       return currentConversation.name;
   return "";
}

public void RestoreFromJToken(JToken state)
{
     currentConversation = Dialogue.GetByName(state.ToObject<string>());
}

For 1, this is only meaningful for enemies that are not Respawnables. Respawnable enemies may or may not exist at the time of RestoreFromJToken. You could use this to cull enemies that are fixed but dead… I’m not sure we wouldn’t do more harm than good with this… We’ll see.

For enemies that are already in the scene (permanent), they should already be in the existing list of enemies if I’m not mistaken. Unless I’ve missed a mechanism where a fixed character might join or leave an aggrogroup (in which case you’re really getting complicated here)…

For 2, groupHatesPlayer, that’s just a bool to add and restore from the State

3 should be already in the inspector for the AggroGroup. It’s meaningless to store state that won’t change.

4 Again… this should already be set in the inspector…

nope, no mechanic for that. All my characters moving forward will be of the works of ‘AggroGroup.cs’, under which ‘RespawnManager.cs’ and then the NPC will be. No fixed characters for me from now on :slight_smile: (just to keep life simple)

I’m assuming this code goes into ‘AIConversant.cs’, and uses ‘currentConversation’ that I created in ‘RespawnManager.cs’? I’m a little confused about this one. Do I delete the ‘AggroGroup.cs’ Saving System that was written earlier?

More importantly, do we get ‘currentConversation’ from ‘RespawnManager’, or just introduce it to ‘AIConversant.cs’? The second approach was something I tried before, and it was all disasters, because the “AIConversant.cs” script dynamically goes and comes from the scene, controlled by RespawnManager, so I eliminated it from “AIConversant.cs” and placed it in “RespawnManager.cs” instead, since “RespawnManager.cs” was static for the scene

I may have further questions down the line, but I’ll go try implement the saving system you provided me with first :smiley:

(Again, I’m sorry for bringing you into this)

It would go on AIConversant. My assumption here was that

  • AIConversant would have a currentConversation so it would know which conversation to present to the character.
    You would then make AIConversant an IJsonSaveable, and it would be captured automagically by the RespawnManager when it collects the data from the saved characters.

Out of all of those 4 things you listed, it honestly LOOKS like the only thing you need to save is the current state of the AggroGroup, is it pissed or not. The RespawnManager should be creating the enemies and adding them to the proper AggroGroup if they are alive (or when they respawn if they’re dead).

public JToken CaptureAsJToken()
{
    return groupHatesPlayer;
}
public bool RestoreFromJToken(JToken state)
{
    groupHatesPlayer = state.ToObject<bool>();
    //call method to aggrevate all existing members of the group
}

Ahh, a few things to help out here:

The idea I had was, since ‘RespawnManager.cs’ was a static script in the hierarchy, i.e: it doesn’t get deleted and re-instantiated, unless we switch scenes (which is when the saving system should work), we can store ‘currentConversation’ in there

and then based on the state of the enemy in the AggroGroup, the plan was to store the value of ‘currentConversation’ in RespawnManager’s Saving and Restoring System (which I successfuly have done), and then use that to properly setup the AIConversant’s “Dialogue” variable

I have gotten 95% of these steps done as we speak, the only issue I had left was that my “AggroGroup.cs” was unable to save who was in and who wasn’t in the group, since as we mentioned, these characters all get instantiated and deleted through gameplay consistently

So, to summarize:

  1. “AIConversant.cs” has the core dialogue, which is whatever the player sees when he tries to talk to an NPC with the dialogue. Any changes in the dialogues that appear to the player are all shown through this variable, and fine-tuning is done through the code to eventually lead to that variable

  2. ‘RespawnManager.cs’ takes care of ‘currentConversation’, the variable that will override ‘dialogue’ in ‘AIConversant.cs’ when the game returns from a saved state (for both saving and restoring), only if ‘currentConversation’ is not null. The reason it’s taken care of in ‘RespawnManager.cs’ was because it never leaves the game scene, unlike the characters themselves, unless the game is quit, at which the saving system stores and loads the values

Essentially, for point number 2, placing ‘currentConversation’ in ‘AIConversant.cs’ runs a higher risk, because the enemies are constantly being killed and respawned, essentially eliminating them from the scene and making it almost impossible to save data using a script this dynamic, so I shifted it to ‘RespawnManager.cs’ (I hope this helps clear the point more than it does harm. I apologize if it was the opposite way around)

  1. ‘AIAggroGroupSwitcher.cs’ will do all the essential switching for both the RespawnManager and AIConversant scripts, when the player invites or eliminates an NPC from his team. This takes care of stuff that happens in ‘RespawnManager.cs’, ‘EnemyStateMachine.cs’ and ‘AIConversant.cs’ as well

  2. ‘AggroGroup.cs’ was supposed to save the player and NPCs into their respected dedicated teams, so that the dialogue fits with where the NPC is at. This was supposed to be the concluding step for this system, at which if it works, the system is officially done

In this case, by static, I really really hope you’re meaning it’s a MonoBehaviour within the scene file added to a GameObject which spawns in a character, like the original prototype. static is a particular term in C#

Then there would be no need for the AIConversant version, though… Not a fan of doing it the way you are doing it overall… because state should be saved as close to the object as possible. It would eliminate a corner case of a dialogue that the enemy might use only if he’s been killed, however, so this may be the better choice.

The only problem is that you have ZERO ways of ensuring that the Aggrogroup is restored AFTER all the characters have been restored… It’s also very difficult to save a reference to a scene object. You would most likely need to save the UUID from the RespawnManager’s IJsonSaveableEntity. Both problems are very real, very tricky ones.

yup, that’s exactly what I meant. Apologies for the confusion :slight_smile:

if I recall correctly, we eliminated the possibility of dead enemies having conversations with the player, through events :slight_smile:

Well, whenever possible, can you please suggest me a better approach than the one I’m taking, since it’s a roadblock that we just encountered? I would truly appreciate it :smiley:

We can scrap the entire thing if we have to… Honestly I wasn’t expecting this concept to be this complicated

I was thinking of the scenario where the character might normally have a dialogue to give you a quest or whatever, but after you kill them and they are respawned, they might have a dialogue that says “Go away, I’m still mad at you for killing me”

Won’t be right away. A lot on my plate right now between the site move and prepping for surgery (5/30, coming soon).

believe it or not, this was the end goal using the predicates, and it was going to be my next step, where killing innocent NPCs can have real consequences on the quests you can do in the game, but what I was trying to do first, was to implement a solution to invite potential NPCs that’ll be your teammates, and also kick them out of the team at the player’s will, so you can have who you want and kick them out when you want as well

The idea I had in mind was to keep the entire ‘currentConversation’ thing as an optional layer, so if it exists we deal with it in a specific way. If not, we can use the default path, so that would allow the scenario you mentioned above, but it would also allow inviting and eliminating NPCs to join your AggroGroup

So I can make bosses really hard for you solo, encouraging you to find people to help you with the fight, which can lead to you having to do quests and what not

I’m just happy for you that you’ll be fine again real soon :smiley:

For now though, do I just delete my entire system, or…? (I’m lowkey convinced deleting it and carefully restarting it with your guidance sounds like a great idea, since my attempt was a disaster… Wait till you see what I did for the AI Fighting System lol) What do you recommend that I should do? I’d appreciate any feedback on this question, because leaving incomplete systems really makes me uncomfortable. If I start something, I’d appreciate either not starting at all, or finishing it before moving on, so I don’t get into coding trouble later on

If only you were using Source Control (git)… This wouldn’t be the tough question you think it is…
You would just create a new branch for features like this, and if they don’t work, you just roll back and work on a new branch, leaving the old branch in place if you need to refer to it.

Typically I make Unity Packages for cases like this, although yes, I agree, I should’ve used Git (PTSD incoming). For now, I have both versions :slight_smile:

You literally have no idea how much simpler your project would be to maintain if you were using .git. That momentary scary Oh My God, Git is going to overwhelm my senses goes away once you actually learn how to use it… and consider this…
Everytime you UnityPackage up the current state of the project, you’re committing the whole thing, so if your assets, for example, take up 200mb, each time you make one of your backups, that’s 200mb of data you’re storing, of which probably 150kb is what you changed…
With git, everytime you commit, you only commit the changes, you only commit the changes, which in many cases can be very tiny. When you restore to a specific commit, it uses the chain of commits up until that point to regenerate everything. It’s extremely efficient… In fact, your way would give me PTSD.

not to scare you, but… That’s like my third 128GB Flash Drive being used to store backups :stuck_out_tongue:

Each time I do such a save, that’s 8.6 Gigabytes (Trust me, I know what you’re thinking right now. I’m just trying to get this project safely on to steam at all costs)

Fine though, I’ll get the Git course along with the Unity C# Programming Patterns course. I want to be able to someday write my own functions as well

It’s a big deal. If you’re at all serious about actually getting hired on by a company (or even working with a team), a solid understanding of Git is a MUST.

If I’m being honest with you, the whole plan of this thing was to actually avoid working for any companies (I don’t hate anyone, just so I’m clear, I honestly just don’t trust big corporations on the long run), but to hopefully start one myself someday (I never told this to anyone before, and now it’s out there lol)

But if it improves my chances of getting a team of professional engineers running, then I’ll take it on :slight_smile:

But then… if you want to attract employees who will be confident that their jobs will last the duration of the project… a thorough understanding of Git on your end will also be a make or break.

Who does? But even a 3 man crew needs Git.

you make a solid point there

that’s… precisely the point, which is why I’m pressurizing on myself for the time being to actually get this thing done right. I won’t even get into the “motivate yourself to finish it” sector. This is my make-it-or-break-it project, and I want to quietly (at least to my family and friends) get it done (the discussion over here are definitely not “quiet”). Depending on how this one project goes, the rest of my life can either go up or down

And I also don’t want to post this project on Kickstarter and make promises I know very well I can’t achieve. I’m sure people are sick of those scams

But I know one thing. If I don’t try, I’ll never know what I missed out on. So… I’m trying, but the AggroGroup save file is giving me sad days ahead. It’s all good though, it’s a lesson for patience for better days, and that’s the best part of it :stuck_out_tongue:

By all means, I’m probably getting the Git course (for now) in a day or two, since my hard drive is running very short on space anyway

Honestly, you me and @bixarrio , an amazing team, and I couldn’t ask for better friends to help me out! :smiley:

So… for the time being, I think I’ll just keep the code as is (I never got a “delete your AggroGroup invitation and expulsion code, we will rewrite something better” or “keep it, we’ll improve it later”). I don’t have a single clue how to save the AggroGroup (albeit I don’t see how it’s so hard either if I’m being honest, since it’s just data transfer… Can’t we save the list as a string and accumulate these again when we restore the game?), but I have faith that one day we will have a better idea to achieve what we need, regardless of how dynamic it is

For now I’ll be off to get some rest, as it’s almost midnight here (and I need to wake up before 6AM, because the sun shines directly into my eyes every single morning, so I want to wake up before it forces me to wake up)

Privacy & Terms