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
- DynamicSpawnZone: Put on a game object, paired with SaveableEntity and DynamicSaving components
- DynamicSpawnTrigger: Put on an existing component that has the requisite logic to trigger your DynamicSpawnZone. I put it on QuestList. QuestList code changes below.
- AbstractConditionObject: I followed Sam’s advice and made the conditions into a scriptable object. This is an abstract class that helps with gradual refactoring.
- 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.
- DeathCounter: Needed small modifications to support saving/loading its own state and to support setting it identifier.
- 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;
}
}
}