I was working on a simple tutorial to guide players through my playtest on my game. I kept it simple where colliders would display different UI Text as they progress through the level, but I never actually checked whether they completed the tasks or restricted them from progressing if they didn’t complete the task.
Does anyone have a nice generic solution for checking the objectives have been completed without having to add code to all your scripts stating an objective has been completed/progressed?
For example, lets say the players needs to kill 10 enemies to progress. Would I need to add code in the Death function for the enemies which calls a function on an Objective Manager every time? It seems sloppy in my brain and I can’t think of a nice clean solution for handling this.
Sorry if this question has been asked before (I’m sure it has), but I can’t seem to find anything.
Ok so I thought up a system for handling this.
Let me know what you guys think, and if you have any ideas for improvements!
ObjectiveManager
Collection of Objectives
function for checking the ActiveObjective for conditions are met - takes an array of strings (actions)
sets the next objective when active object is complete
Objective
array of strings which are “conditions” for the objective to increment in completion
a completion float - reaching a value of 1 means the objective is complete
completion increment - when the conditions are met, how much should we increment completion?
checkConditions - takes a string of actions and if they ALL match the conditions, increment the completion float (it’s ok to have more actions than the conditions)
How to implement
Create the objective objects at an appropriate point e.g. when the scene loads
Add the objectives to the ObjectiveManager.objectives in order
Set the ObjectiveManager.activeObjective to the first one
When an objective event occurs (e.g. Death), call ObjectiveManager.CheckActiveObjective and pass through the actions array for said event
Limitations:
currently only 1 active objective
the actions and conditions must be in the exact same order
Why an array of actions/conditions?
You may want a generic condition where if anything dies, the completion increments
Or you may want to be more specific and say when something dies, and it’s tag is “enemy”
This allows you to have 1 call to the objective manager per action, which can provide limitless parameters
Code (semi pseudo, haven’t tested): ```csharp
**public class ObjectiveManager : MonoBehavior
{
public List objectives = new List();
public Objective activeObjective;
private int objectiveIndex = 0;
public void CheckActiveObjective(string[] actions)
{
if (activeObjective != null)
{
//Check if the actions meet the conditions then check if the objective is complete
activeObjective.checkConditions(actions);
if (activeObjective.completion >= 1)
{
//Objective complete!
objectiveIndex++;
if (objectives.Count > objectiveIndex)
{
//Assign the next objective
activeObjective = objectives[objectiveIndex];
}
else
{
//No objects left
activeObjective = null;
}
//TODO:
//Update some counter on the UI
//Play a sound
//Play animation
}
}
}
}
public class Objective
{
public float completion = 0f;
//array of strings to identify the conditions for incrementing objective completion
private string _conditions;
//how much to increment the completion when the conditions are met. 1 = 1 action, 0.5 = 2 actions etc
private float _completionIncrement = 1f;
public void checkConditions(string actions)
{
bool conditionsMet;
for(int i = 0; i < actions.length; i++)
{
//check the length in case there are more actions than conditions
if (conditionsMet.length >= i)
{
if (conditions[i] == actions[i])
{
conditionsMet = true;
}
else
{
conditionsMet = false;
break;
}
}
}
if (conditionsMet)
{
completion += completionIncrement;
}
}
}
public class TutorialObjectives : MonoBehavior
{
void Start()
{
//Objective to kill 10 enemies
string conditions = {“death”, “enemy”};
Objective objective = new Objective(conditions, 0.1f);
ObjectiveManager.objectives.Add(objective);
ObjectiveManager.activeObjective = objective;
//Objective to reach the camp
conditions = {"reachedCamp"};
objective = new Objective(conditions, 1f);
ObjectiveManager.objectives.Add(objective);
}
}
//Example calls to check objectives
//Inside the health Script
public void Death()
{
ObjectiveManager.CheckActiveObjective(new string {“death”, gameObject.tag});
I like your approach. You can simplify it a bit by simplifying the concept of an objective a little more.
You could try viewing all the objectives, conditions, etc as a true/false condition. So either it’s complete or not.
Then you may want to be able to keep track of progress towards completion. Something like 0 → 100%.
The objective shouldn’t know the conditions in which it is incremented or when it should gain progress. That should be left to other logic. For example, if you have a condition where 10 enemies must be slain, whenever an enemy is slain, that logic should trigger some event that can increment the progress of that objective.
You can wrap these objective objects in some other concept if you want to be able to disable/enable, add some ordering, etc.
This is similar to how an achievement or analytics system would be implemented. Which objectives is very similar to, basically a progress and maxProgress.
I first want to point out that creating arrays like that every time you want to check an objective can be very garbage heavy.
With that said.
I’ll highlight a generic overview of the design we go with.
…
For starters all my in game things that are of importance (players, mobs, pickups, etc), if it’s a complex entity in the scene and not just background stuff, they get an ‘Entity’ script attached to it. This is my base Entity script:
The specifics of this class isn’t super important. The general idea though is that the script is placed on the root of the entity (since many entities, especially those with rigs, have many children). This script acts as a standard entry point to the entity, and it can be quickly looked up from a table.
Next I really like the unity messaging system which uses interfaces to allow dispataching messages to objects in a more controlled manner (think like SendMessage, but instead of strings, we get to use interfaces): https://docs.unity3d.com/Manual/MessagingSystem.html
Thing is that it doesn’t fully support BroadcastMessage, so I implemented some extras for it:
One key extra is the BroadcastGlobal. This is what I will hook into.
So lets say I wanted to track death. So I create an interface for that. Here I have 2 interfaces for it, one generic, the other player specific:
public interface IDeathHandler
{
void OnDeath(IEntity entity, object implementOfDeath);
}
public interface IPlayerDeathGlobalHandler
{
void OnDeath(IEntity playerEntity, object implementOfDeath);
}
And here you can see me broadcasting them in my HealthMeter script:
using UnityEngine;
using System.Collections.Generic;
using com.spacepuppy;
using com.spacepuppy.Scenario;
using com.spacepuppy.Utils;
using com.mansion.Entities.Weapons;
using com.mansion.Messages;
namespace com.mansion
{
[UniqueToEntity()]
public class HealthMeter : SPComponent, IObservableTrigger
{
public enum StatusType
{
Healthy,
Injured,
Critical,
Dead
}
#region Fields
[SerializeField()]
private float _health;
[SerializeField()]
[Tooltip("0 or negative means infinite.")]
private float _maxHealth;
[SerializeField()]
[Range(0f, 1f)]
private float _injuredRatio = 0.7f;
[SerializeField()]
[Range(0f, 1f)]
private float _criticalRatio = 0.35f;
[SerializeField()]
private bool _destroyEntityOnOutOfHealth;
[SerializeField()]
[Tooltip("Occurs on any strike that does not cause death.")]
private Trigger _onStrike;
[SerializeField()]
[Tooltip("Occurs when health reaches 0")]
private Trigger _onDeath;
[System.NonSerialized]
private IEntity _entity;
#endregion
#region CONSTRUCTOR
protected override void Awake()
{
base.Awake();
_entity = IEntity.Pool.GetFromSource<IEntity>(this);
}
#endregion
#region Properties
public float Health
{
get { return _health; }
set
{
this.SetHealth(value, true);
}
}
public float MaxHealth
{
get { return _maxHealth; }
set
{
_maxHealth = value;
if(_maxHealth > 0f && _maxHealth < _health)
{
_health = _maxHealth;
}
}
}
public bool DestroyEntityOnOutOfHealth
{
get { return _destroyEntityOnOutOfHealth; }
set { _destroyEntityOnOutOfHealth = value; }
}
public StatusType Status
{
get
{
if(_maxHealth <= 0f || float.IsInfinity(_maxHealth) || float.IsNaN(_maxHealth))
{
return (_health > 0f) ? StatusType.Healthy : StatusType.Dead;
}
else
{
var ratio = _health / _maxHealth;
if (ratio > _injuredRatio)
return StatusType.Healthy;
else if (ratio > _criticalRatio)
return StatusType.Injured;
else if (ratio > 0f)
return StatusType.Critical;
else
return StatusType.Dead;
}
}
}
public bool IsDead
{
get { return this.Status == StatusType.Dead; }
}
public Trigger OnStrike
{
get { return _onStrike; }
}
public Trigger OnDeath
{
get { return _onDeath; }
}
#endregion
#region Methods
public void SetHealth(float health, bool signalDeath = false)
{
bool wasAlive = (_health > 0f);
if (_maxHealth > 0f)
_health = Mathf.Clamp(health, 0f, _maxHealth);
else
_health = Mathf.Max(health, 0f);
if (signalDeath && wasAlive && _health <= 0f)
this.OnDie(null);
}
public bool StrikeWouldKill(float damage)
{
return _health > 0f && (_health - damage) <= 0f;
}
public bool StrikeWouldKill(IWeapon wpn)
{
return _health > 0f && wpn != null && (_health - wpn.Damage) <= 0f;
}
/// <summary>
/// Strike the health meter, returns true if died.
/// </summary>
/// <param name="wpn"></param>
/// <returns></returns>
public bool Strike(float damage)
{
if (_health <= 0f) return false;
_health = Mathf.Max(_health - damage, 0f);
if (_maxHealth > 0f && _health > _maxHealth)
{
_health = _maxHealth;
}
if (_health == 0f)
{
this.OnDie(null);
return true;
}
else
{
this.OnStruck(null);
return false;
}
}
/// <summary>
/// Strike the health meter, returns true if died.
/// </summary>
/// <param name="wpn"></param>
/// <returns></returns>
public bool Strike(IWeapon wpn)
{
if (wpn == null) return false;
if (_health <= 0f) return false;
_health = Mathf.Max(_health - wpn.Damage, 0f);
if (_maxHealth > 0f && _health > _maxHealth)
{
_health = _maxHealth;
}
if (_health == 0f)
{
this.OnDie(wpn);
return true;
}
else
{
this.OnStruck(wpn);
return false;
}
}
private void OnDie(object implementOfDeath)
{
//adjust stats
if (_entity != null && Game.Scenario.Statistics != null)
{
switch (_entity.Type)
{
case IEntity.EntityType.Player:
Game.Scenario.Statistics.AdjustStatRanking(GameStatistics.PROP_TAKEDAMAGE, 1);
Game.Scenario.Statistics.AdjustStatRanking(GameStatistics.PROP_DEATHS, 1);
break;
case IEntity.EntityType.Mob:
var entityKiller = IEntity.Pool.GetFromSource<IEntity>(implementOfDeath);
if (entityKiller != null && entityKiller.Type == IEntity.EntityType.Player)
Game.Scenario.Statistics.AdjustStatRanking(GameStatistics.PROP_MOBSKILLED, 1);
break;
}
}
//trigger events
_entity.gameObject.Broadcast<IStruckHandler>((o) => { o.OnStruck(_entity, implementOfDeath, true); });
_entity.gameObject.Broadcast<IDeathHandler>((o) => { o.OnDeath(_entity, implementOfDeath); });
if (_entity != null && _entity.Type == IEntity.EntityType.Player)
Messaging.Broadcast<IPlayerDeathGlobalHandler>((o) => { o.OnDeath(_entity, implementOfDeath); });
if (_onDeath.Count > 0) _onDeath.ActivateTrigger(this, implementOfDeath);
if (_destroyEntityOnOutOfHealth)
{
var e = SPEntity.Pool.GetFromSource(this);
GameObjectUtil.KillEntity(e.gameObject);
}
}
private void OnStruck(object implementOfStrike)
{
//adjust stats
if (_entity != null && Game.Scenario.Statistics != null)
{
switch (_entity.Type)
{
case IEntity.EntityType.Player:
Game.Scenario.Statistics.AdjustStatRanking(GameStatistics.PROP_TAKEDAMAGE, 1);
break;
}
}
//trigger events
_entity.gameObject.Broadcast<IStruckHandler>((o) => { o.OnStruck(_entity, implementOfStrike, false); });
if (_onStrike.Count > 0)
{
_onStrike.ActivateTrigger(this, implementOfStrike);
}
}
#endregion
#region IObservableTrigger
Trigger[] IObservableTrigger.GetTriggers()
{
return new Trigger[] { _onStrike, _onDeath };
}
#endregion
#region Special Types
private struct HealthToken
{
public float Health;
public float MaxHealth;
}
#endregion
}
}
Note I only globally broadcast the player death, that’s just because my game doesn’t really care about others. But you could broadcast anything really.
Anyways.
Note that I pass along the entity that was killed, and the implement that killed it (from which we can look up the entity that wielded it).
We can then have objectives that start up and just listen for these messages. So lets say we wanted to kill 10 pigs… maybe we ‘tag’ the entity as ‘Pig’ and we just need to count up 10 of them:
public class KillPigsObjective : MonoBehaviour, IDeathHandler
{
public int TotalCount = 10;
public UnityEvent OnObjectiveMet;
private int _count;
void OnEnable()
{
if(_count < this.TotalCount)
Messaging.RegisterGlobal<IDeathHandler>(this);
}
void OnDisable()
{
Messaging.UnregisterGlobal<IDeathHandler>(this);
}
void IDeathHandler.OnDeath(IEntity entity, object implementOfDeath)
{
if (!entity.gameObject.CompareTag("Pig")) return;
var murderer = SPEntity.Pool.GetFromSource(implementOfDeath);
if (murderer == null || !murderer.gameObject.CompareTag("Player")) return;
//you could test for other stuff here. Say the weapon used must be a sword, you can test that.
_count++;
if(_count >= this.TotalCount)
{
Messaging.UnregisterGlobal<IDeathHandler>(this);
OnObjectiveMet.Invoke(); //you can use this to do stuff on objective completion
}
}
}
To start an objective, you just create a GameObject with this script on it. And wait for the UnityEvent to fire that it was completed.
@lordofduct That’s incredible, I have never seen such well structured code.
I haven’t had much experience with Broadcasts and Interfaces but it looks like it will be super handy!
Do you know of any good resources/exercises for getting a better understand of these concepts?
Just so I’m clear, in your last sentence could you have an ObjectivesHolder gameobject and just add the KillPigsObjective component to that gameobject? Could I add multiple objectives to the same gameobject?
Or do you need to create a new gameobject for each objective?