Quest Triggered, Procedurally Spawned Characters, in an Arbitrary Scene

So first off, I’m happy to say I have it working! Did I also say that it works with saving and loading? Because it does! I will share code below.

EDIT (24 Aug 2023): flipped from “ask” to “show” based on a quick review from Brian. If anyone has any advice on fixing some of the hacks please pile on. I hope this is useful to you all!

But before that, special thanks to Brian. I used his tips and tricks on Procedurally Spawned Characters and Tracking kills for Quests? to make this happen. I had to make a few small tweaks, which I’ll share.

Key Features

  • Dynamically Spawn multiple character types based on a quest objective
  • Define Quest Conditions based on spawn configuration
  • Trigger spawning them in a scene of your choosing.
  • randomly distribute spawned characters in a defined zone when you within a predefined distance
  • spawned characters guaranteed to be walkable from your spawn point
  • performance optimizations to make sure large spawned character trigger doesn’t tank your frame rate
  • Supports save and load of progress (which enemies you knocked out, which are left, quest status)
  • Quest objective success depends on “knocking out” the requisite bad guys. I say “knock out” because you can use for purposes other than killing.
  • UI formatter to automatically render objective status (like Diablo)
  • Minimal duplication of data, minimal magic strings, etc. For example quest objective definition and spawn configuration is all handled in the same object. Reduces errors resulting from setting the wrong string somewhere. Quest Triggering also no longer needs magic strings

Limitations

  • Haven’t gone through PropertyDrawer Stuff yet. I think that should fix a few hacks
  • lots of hacks in QuestStatus.cs – Advice here would be helpful.
  • saving whether we were able to successfully spawn the quest triggered characters is currently all or nothing right now. This needs to be fixed, but I feel I can do this.

Future Work

  • I want to be able to procedurally generate quests. You’ll see the code below has early hooks for that. I’m still thinking through this one.

Description of Changes

  1. DynamicSpawnZone: Put on a game object, paired with SaveableEntity and DynamicSaving components
  2. DynamicSpawnTrigger: Put on an existing component that has the requisite logic to trigger your DynamicSpawnZone. I put it on QuestList. QuestList code changes below.
  3. AbstractConditionObject: I followed Sam’s advice and made the conditions into a scriptable object. This is an abstract class that helps with gradual refactoring.
  4. SpawnObjectiveCondition: This extends the abstact class. But honestly, I think QuestObjectives may need to become SO’s also once you see how ugly this is. Note this class is designed to work with above two Dynamic* stuff. The DynamicSpawnZone tells the IDynamicSpawnTrigger that it got too close and provides a reference back to itself. The IDynamicSpawnTrigger can then pass that reference down to the object that contains the logic for what to spawn, respawn, etc. DynamicSpawnZone does not contain logic for what to spawn.
  5. DeathCounter: Needed small modifications to support saving/loading its own state and to support setting it identifier.
  6. QuestStatus: A hacky mess of changes

Pay attention to the little flag that shows when counter hit the goal number

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

namespace RPG.Dynamic
{
    public class DynamicSpawnZone : MonoBehaviour
    {
        [SerializeField] private string spawnPointName;
        [SerializeField] private LayerMask interactableLayerMask;
        [SerializeField] private float triggerDistance = 40f;
        [SerializeField] private float scatterDistance = 30f;
        [SerializeField] private float navMeshSamplingRange = 0.1f;
        [SerializeField] private float maxWalkableDistance = 30f;

        Queue<Vector3> precomputedSpawnPoints = new Queue<Vector3>();
        private DynamicSaving dynamicSavingComponent;

        // CONSTANTS
        const int PRECOMPUTE_ATTEMPTS = 400;

        private void Awake()
        {
            dynamicSavingComponent = GetComponent<DynamicSaving>();
            float startTime = Time.realtimeSinceStartup;
            PrecomputeSpawnPoints(PRECOMPUTE_ATTEMPTS);
            float endTime = Time.realtimeSinceStartup;
            float milliSeconds = Mathf.RoundToInt((endTime - startTime) * 10000)/10f;
            Debug.Log($"it took {milliSeconds} milliseconds to make {PRECOMPUTE_ATTEMPTS} " +
                      $"attempts and generate {precomputedSpawnPoints.Count} points");

            //TODO: You may want to try to generate more points if testing shows that you are
            //not generating enough on the first run. For reference it takes 4.0 to 2.9 ms
            //to make 200 attempts on my laptop and my scene gets a 75% success rate.
        }

        private void Update()
        {
            RaycastHit[] hits = Physics.SphereCastAll(transform.position, triggerDistance, Vector3.up, 0, interactableLayerMask);
            foreach (RaycastHit hit in hits)
            {
                IDynamicSpawnTrigger trigger = hit.collider.GetComponent<IDynamicSpawnTrigger>();
                if (trigger == null) continue;
                trigger.NotifyInRange(this);
            }
        }

        public DynamicEntity RandomSpawn(DynamicCharacterConfiguration config)
        {
            // this method is intentionally implemented to use precomputed spawn points because the
            // expectation is that it will be called by an expensive operation that first determines
            // if the spawn should happen.
            Vector3 randomPosition = GetNextRandomPosition();
            return dynamicSavingComponent.CreateDynamicEntity(config, randomPosition, new Vector3(0, 0, 0));
        }

        private Vector3 GetNextRandomPosition()
        {
            Vector3 vector3 = precomputedSpawnPoints.Dequeue();
            precomputedSpawnPoints.Enqueue(vector3);
            return vector3;
        }

        private void PrecomputeSpawnPoints(int numAttempts)
        {
            for (int i = 0; i < numAttempts; i++)
            {
                Vector2 randomCirclePoint = Random.insideUnitCircle * scatterDistance;
                Vector3 randomPoint = transform.position +
                    new Vector3(randomCirclePoint.x, 0, randomCirclePoint.y);
                NavMeshHit hit;
                if (NavMesh.SamplePosition(randomPoint, out hit, navMeshSamplingRange, NavMesh.AllAreas))
                {
                    if (CanMoveTo(hit.position))
                    {
                        precomputedSpawnPoints.Enqueue(new Vector3(hit.position.x, hit.position.y, hit.position.z));
                    }
                }
            }
        }

        private bool CanMoveTo(Vector3 destination)
        {
            NavMeshPath path = new NavMeshPath();
            bool hasPath = NavMesh.CalculatePath(transform.position, destination, NavMesh.AllAreas, path);
            if (!hasPath) return false;
            if (path.status != NavMeshPathStatus.PathComplete) return false;

            if ((GetPathLength(path) > maxWalkableDistance)) return false;

            return true;
        }

        private float GetPathLength(NavMeshPath path)
        {
            float total = 0;
            if (path.corners.Length < 2) return total;

            for (int i = 0; i < path.corners.Length - 1; i++)
            {
                // this can be optimized but it's probably not the most expensive computation
                total += Vector3.Distance(path.corners[i], path.corners[i + 1]);
            }
            return total;
        }

        private void OnDrawGizmosSelected()
        {
            Gizmos.color = Color.blue;
            Gizmos.DrawWireSphere(transform.position, triggerDistance);
        }
    }
}
namespace RPG.Dynamic
{
    public interface IDynamicSpawnTrigger
    {
        public void NotifyInRange(DynamicSpawnZone dynamicSpawnZone);
    }

}
// method inside QuestList
public void NotifyInRange(DynamicSpawnZone dynamicSpawnZone)
        {
            foreach (QuestStatus status in GetStatuses())
            {
                status.SpawnInScene(dynamicSpawnZone);
            }
        }
///key code changes inside QuestStatus
        private Dictionary<int, bool> hasSpawnedInScene = new Dictionary<int, bool>();

        [System.Serializable]
        class QuestStatusRecord
        {
            public string questName;
            public List<string> completedObjectives;
            public Dictionary<int, bool> hasSpawnedInScene;
        }

        public QuestStatus(object objectState)
        {
            QuestStatusRecord state = objectState as QuestStatusRecord;

            quest = Quest.GetByName(state.questName);
            completedObjectives = state.completedObjectives;
            hasSpawnedInScene = state.hasSpawnedInScene;
        }

public string GetObjectiveStatusText(string objectiveReference)
        {
            AchievementCounter localCounter = GameObject.FindWithTag("Player").GetComponent<AchievementCounter>();

            string dynamicObjectiveText = null;
            foreach (var objective in quest.GetObjectives())
            {
                // this code is a complete mess!!!!!
                // I think the correct thing here is for an objective to be able to format itself.
                Debug.Log($"Attempting to format objective {objective.reference} with description {objective.description}");
                if (objective.reference == objectiveReference)
                {
                    if (objective.usesCondition && objective.completionCondition != null)
                    {
                        var objectiveCondition = objective.completionCondition as SpawnObjectiveCondition;
                        if (objectiveCondition == null)
                        {
                            Debug.LogWarning($"objective {objective.reference} is incorrectly set up for formatting");
                            return objective.description;
                        }
                        return objectiveCondition.FormatObjectiveStatus();
                    }
                    else
                    {
                        Debug.Log($"Objective {objective.reference} had a static description: {objective.description}");
                        return objective.description;
                    }
                }
            }
            Debug.LogWarning($"Objective {objectiveReference} didn't exist. Returning null.");
            return dynamicObjectiveText;
        }



        public void SpawnInScene(DynamicSpawnZone dynamicSpawnZone)
        {
            int currentSceneIndex = SceneManager.GetActiveScene().buildIndex;

            if (hasSpawnedInScene.ContainsKey(currentSceneIndex))
            {
                if (hasSpawnedInScene[currentSceneIndex])
                {
                    return;
                }
            }
            bool wasSuccessful = false;

            // TODO: note this does not handle partial success.
            // maybe condition can return a list of spawnables and the QuestStatus instance
            // can keep track of which ones have spawned.
            Debug.Log("Attempting to spawn outside Quest Status for loop");
            foreach (var objective in quest.GetObjectives())
            {
                var objectiveCondition = objective.completionCondition as SpawnObjectiveCondition;
                if (objectiveCondition == null) continue;
                Debug.Log("Attempting to spawn in Quest Status for loop");
                if (objectiveCondition.TrySpawnInZone(dynamicSpawnZone))
                {
                    wasSuccessful = true;
                }
            }
            hasSpawnedInScene[currentSceneIndex] = wasSuccessful;
        }

        public object CaptureState()
        {
            QuestStatusRecord state = new QuestStatusRecord();
            //TODO: I don't know yet.  I think it will be
            // state.currentQuestConfig = currentQuestConfig.CaptureState();
            state.questName = quest.GetTitle();
            state.completedObjectives = completedObjectives;
            state.hasSpawnedInScene = hasSpawnedInScene;
            return state;
        }
using UnityEngine;
using RPG.Core;
using RPG.Dynamic;
using System.Collections.Generic;
using UnityEngine.SceneManagement;
using System;

namespace RPG.Quests
{
    [CreateAssetMenu(fileName = "SpawnObjectiveCondition", menuName = "RPG/Quest/New Spawn Objective Condition", order = 0)]

    public class SpawnObjectiveCondition : AbstractConditionObject, ISerializationCallbackReceiver
    {
        // A thing to note here. This is not just the condition but it's really a fully formed objective.
        // The objective's description will be automatically generated by what you put in the fields below.
        // the objective reference and unique ID here should probably also be merged.
        //
        // I think the correct approach is to have Quest use these kinds of objects *instead of*
        // Quest.Objective.
        [SerializeField] private string objectiveDescriptionPrefix = "Knock Out";
        [SerializeField] private int goalToKnockOut = 0; //i.e. parameters[1]
        [SerializeField] private string objectiveDescriptionSuffix = "Enemies";
        [SerializeField] private List<SpawnConfig> spawnConfigs;
        [NonSerialized] private string predicate = "HasKnockedOut";
        private string uniqueID; //i.e. parameters[0]
        [NonSerialized] private string[] parameters = new string[2];

        [System.Serializable]
        class SpawnConfig
        {
            [SerializeField] public DynamicCharacterConfiguration characterConfig;
            [SerializeField] public int count;
            [SerializeField] public int sceneIndex;
        }

        private void OnValidate()
        {
            int checkSum = 0;
            foreach (var spawnConfig in spawnConfigs)
            {
                checkSum += spawnConfig.count;
            }
            if (goalToKnockOut > checkSum)
            {
                goalToKnockOut = checkSum;

                Debug.LogWarning("Warning - you have set up an objective where the" +
                    "player must knock out more objects than you have configured to spawn.  " +
                    "The goal was reset to the sum of spawnconfigs. You may make the goal " +
                    "equal or lower.");
            }
            parameters[0] = uniqueID;
            parameters[1] = goalToKnockOut.ToString();
            Debug.Log($"Predicate in OnValidate: {predicate} . Params are {parameters[0]} and {parameters[1]}");
        }


        /// <summary>
        /// This is for future use. I want to be able to do something like:
        ///      List<SpawnObjectiveCondition> objectives = new List<SpawnObjectiveCondition>();
        ///      SpawnObjectiveCondition customObjective = CreateInstance<SpawnObjectiveCondition()>();
        ///      customObjective.Setup(... parameters...);
        ///      objectives.Add(condition);
        ///      
        ///      QuestStatus quest = new QuestStatus(objectives, reward);
        ///      
        /// to procedurally create quests and their objectives
        /// </summary>
        public void Setup()
        {

        }

        public string FormatObjectiveStatus()
        {
            AchievementCounter counter = GameObject.FindWithTag("Player").GetComponent<AchievementCounter>();
            if (counter == null)
            {
                Debug.LogWarning("Missing counter to format condition based objective");
                return "Missing Conditional Data";
            }

            string numerator = "<color=green>" + counter.GetCounterValue(uniqueID) + "</color>";
            string goal = "<color=green>" + goalToKnockOut + "</color>";

            return objectiveDescriptionPrefix + " " + goal + " " + objectiveDescriptionSuffix + ": "
                + numerator + " / " + goal;
        }

        public bool TrySpawnInZone(DynamicSpawnZone dynamicSpawnZone)
        {
            int currentSceneIndex = SceneManager.GetActiveScene().buildIndex;
            bool wasSuccessful = false;
            //TODO: Two pieces
            // 1. This shuld be optimized to use a different data structure
            // that makes looksups faster. This is double for loop. The caller may
            // also be calling this within for loop(s).
            // 2. The return value does not correctly handle partial success.

            foreach (var spawnConfig in spawnConfigs)
            {
                if (spawnConfig.sceneIndex == currentSceneIndex)
                {
                    for (int i = 0; i < spawnConfig.count; i++)
                    {
                        Debug.Log($"Attempting to spawn {i} of {spawnConfig.count}");
                        DynamicEntity entity = dynamicSpawnZone.RandomSpawn(spawnConfig.characterConfig);
                        if (entity.gameObject.TryGetComponent<DeathCounter>(out DeathCounter deathcounter))
                        {
                            deathcounter.SetIdentifier(uniqueID);
                        }
                        wasSuccessful = true;
                    }
                }
            }
            return wasSuccessful;
        }


        public int GetGoalTotal()
        {
            return goalToKnockOut;
        }

        /// <summary>
        /// When spawning dynamic characters you must set the death counter component to be
        /// equal to the unique identifier returned by this method.
        /// </summary>
        /// <returns></returns>
        public string GetUniqueID()
        {
            return uniqueID;
        }


        public override bool Check(IEnumerable<IPredicateEvaluator> evaluators)
        {
            foreach (var evaluator in evaluators)
            {
                bool? result = evaluator.Evaluate(predicate, parameters);
                if (result == null)
                {
                    continue;
                }
                //note I am not using negate here
                if (result == false) return false;
            }
            return true;
        }

        public void OnBeforeSerialize()
        {
            if (string.IsNullOrWhiteSpace(uniqueID))
            {
                uniqueID = System.Guid.NewGuid().ToString();
            }
        }

        public void OnAfterDeserialize()
        {
            // Require by the ISerializationCallbackReceiver but we don't need
            // to do anything with it.
        }
    }
}
using System.Collections.Generic;
using UnityEngine;

namespace RPG.Core
{
    public abstract class AbstractConditionObject : ScriptableObject
    {
        public abstract bool Check(IEnumerable<IPredicateEvaluator> evaluators);
    }
}
//key changes inside Quest.  ITriggerable is an interface that allows you to Trigger Actions from Dialogue Nodes without magic strings.  Here is an example of a quest being given.

namespace RPG.Quests
{
    [CreateAssetMenu(fileName = "Quest", menuName = "RPG/Quest/NewQuest", order = 0)]
    public class Quest : ScriptableObject, ITriggerable
    {

        [System.Serializable]
        public class Objective
        {
            public string reference;
            public string description;
            public bool usesCondition = false;
            public AbstractConditionObject completionCondition;
        }

        public void Trigger()
        {
            QuestList questList = GameObject.FindGameObjectWithTag("Player").GetComponent<QuestList>();
            questList.AddQuest(this);
        }
    }
}
using GameDevTV.Saving;
using RPG.Attributes;
using UnityEngine;

namespace RPG.Quests
{
    public class DeathCounter : MonoBehaviour, ISaveable
    {
        [SerializeField] private string identifier;
        [SerializeField] private bool onlyIfInitialized = true;

        private AchievementCounter counter;

        [System.Serializable]
        class CounterStatusRecord
        {
            public string identifier;
            public bool onlyIfInitialized;
        }

            public void SetIdentifier(string identifier)
        {
            this.identifier = identifier;
        }

        private void Awake()
        {
            counter = GameObject.FindWithTag("Player").GetComponent<AchievementCounter>();
            
        }

        private void OnEnable()
        {
            GetComponent<Health>().CharacterDied += AddToCount;
        }

        private void OnDisable()
        {
            GetComponent<Health>().CharacterDied -= AddToCount;
        }

        private void AddToCount()
        {
            counter.AddToCount(identifier, 1, onlyIfInitialized);
        }

        public object CaptureState()
        {
            CounterStatusRecord state = new CounterStatusRecord();
            state.identifier = identifier;
            state.onlyIfInitialized = onlyIfInitialized;
            return state;
        }

        public void RestoreState(object objectState)
        {
            CounterStatusRecord state = objectState as CounterStatusRecord;
            this.identifier = state.identifier;
            this.onlyIfInitialized = state.onlyIfInitialized;
        }
    }
}

At this point in development, the important thing is getting it working, which you have.

The biggest issue I see, as you’re clearly getting closer to something releaseable is that you’re still using BinaryFormatter… This is a good time to switch to the Json Saving System.

You’ve got a lot of great ideas in here, well done!

I don’t have time for a complete codereview on the entire list of scripts (or I won’t have time to help other students, eat, sleep before my day job! LOL).

So looking over what you’ve got in QuestStatus, I think you’re on the right track, but… I can see you’re looking to track which members were actually spawned…

Rather than passing a bool, what you may wish to consider is passing a bit mask. With a bit mask (which is a glorified integer), you can actually pass 32 boolean values around as one concise int which can be saved with either BinaryFormatter or the Json system.

Here’s a handy struct that serves as a bitfield:

namespace HandyUtilities
{
    public struct BitField
    {
        //For CaptureState, you'll return your bitField.Value, much like LazyValues
        public int Value { get; private set; }
        //Use this in RestoreState()
        public BitField(int value)
        {
            Value = value;
        }
        /// <summary>
        /// Gets a bit from the array.  The index must be between 0 and 31 because our backing field (int) contains
        /// 32 bits.
        /// </summary>
        /// <param name="index">index into the bitfield, must be between 0 and 31 inclusive</param>
        /// <returns>boolean value at index</returns>
        public bool GetBitValue(int index)
        {
            if (index < 0 || index > 31) return false;
            return (Value & 1 << index)>0;
        }
        /// <summary>
        /// Sets a  bit value in the array.  The index must be between 0 and 31 because our backing field contains 32 bits. 
        /// </summary>
        /// <param name="index">index into the bitfield, must be between 0 and 31 inclusive</param>
        /// <param name="value">bit value to set</param>
        public void SetBitValue(int index, bool value)
        {
            if (index < 0 || index > 31) return;
            if (GetBitValue(index) && !value)
            {
                //Clear the bit.  -1 is an int with all bits set to 1.  By XORing this with 1<<index, the bit at that index 
                //is cleared.
                int clearMask = -1 ^ 1 << index; //Get mask of all true except index.
                Value &= clearMask;
            }
            //if value is true, then we need to set the bit at index.  If it's false, we know from the previous if operation
            //that the value is already set to false at this point.
            if (value)
            {
                Value |= 1 << index;
            }
        }
    }
}

In QuestList, instead of a Dictionary<int, bool> (for scene and has spawned), instead use Dictionary<int, int>.
Change the header for TrySpawnInZone to:

public int TrySpawnInZone(DynamicSpawnZone dynamicSpawnZone, int bitMask)

You’re going to pass the int at SceneIndex to the TrySpawnInZone, and you’ll store the result back into the Dictionary.

Within your for loop, rather than using unsuccessful, use

BitField successField = new BitField(bitMask); 

Then in the integer loop,

                    for (int i = 0; i < spawnConfig.count; i++)
                    {
                        if(successField.GetBitValue(i)) continue; //already spawned;
                        Debug.Log($"Attempting to spawn {i} of {spawnConfig.count}");
                        DynamicEntity entity = dynamicSpawnZone.RandomSpawn(spawnConfig.characterConfig);
                        if (entity.gameObject.TryGetComponent<DeathCounter>(out DeathCounter deathcounter))
                        {
                            deathcounter.SetIdentifier(uniqueID);
                        }
                        successField.SetValue(i, true);
                    }

and at the end of the method

return successField.value;

This would allow for some other condition needing to be satisfied to spawn all the characters…

Totally get it. The targeted advice you provided in the response is what I needed. 90% of the reason of my post is to provide something for other students. This was a beast for me to figure out and I hope it helps others. I could not have done it without your collection of tips-and-tricks. Most of what I did was find a way to integrate them together.

I’ll flip this to “show” in case people want to riff on it.

Thanks. My near-term roadmap is to complete (parts of) Shops and Abilities, implement pausing, do the JSON Saving System, and go in game designer mode and add some content for play testing. The procedurally generated stuff will make my life easier in game designer mode. My aggressive goal is to release a play testing demo after US labor day.

Yup. I was thinking of a bitmask. I don’t think I’ll ever have more than 32 spawnable characters for a SpawnObjectiveCondition so this should do just fine. Thanks for the Handy Utility!

This topic was automatically closed 20 days after the last reply. New replies are no longer allowed.

Privacy & Terms