Tracking kills for Quests?

Hello Brian,

I was hoping to get some guidance or an answer on how to devise a system to incorperate into our quests that would allow me to track kills for a quest?

I was hoping for something along the lines of when a monster dies, it gets the component and checks for a behaviour that implements an interface and evaluates the kill data to see if it contains the string name or perhaps some other id and matches it against the quests in the players quest list to see if it meets the criteria for the objective and if it does it adds a tally to that to move the player towards completing the objective and ultimate the quest.

The way I started doing it was to try and work it into our predicate system, including adding an enum that corresponds to “HasKilled” and seeing if I could evaluate the players quest list for a “Has killed” predicate and if so check the paramater to see if it matches the enemy that died in one way or another and then store that kill towards completing the objective.

However I am not sure if this is the right way or the most sensible way to do this? Am I overcomplicating it? Is this moving in the right direction? Can you help me move this system forward into a functioning implementation for tracking kills for quests that require kill counts?

So far my quest contains the predicate and I added to the editor a way to make the “haskilled” enum appear as you showed us how to do with the other properties.

Thanks for your help!
Kindly,
A

This particular scenario is a tricky one, because our current QuestStatus is only set up to record that an objective has been completed, not the number of times that objective has been completed. I’m not entirely convinced we want to make a large scale structural change to QuestStatus, but we may need to. We’ll see if we can work around this idea.

I’m thinking the best way to deal with this is to add another component that can act as a generic counter system. This counter system will also double as an achievement counter, if we design it correctly.

I’m just getting ready to head to work, so I don’t have the whole thing fleshed out yet, but we’ll start with the obvious:

Here’s the basic outline of a generic counter:

using System.Collections;
using System.Collections.Generic;
using GameDevTV.Saving;
using UnityEngine;

public class AchievementCounter : MonoBehaviour, ISaveable
{
    private Dictionary<string, int> counts = new Dictionary<string, int>();

    public int AddToCount(string token, int amount, bool onlyIfNew=false)
    {
        if (!counts.ContainsKey(token))
        {
            if (onlyIfNew) return 0;
            counts[token] = amount;
            return amount;
        }
        counts[token] += amount;
        return counts[token];
    }

    public int RegisterCounter(string token, bool overwrite=false)
    {
        if (!counts.ContainsKey(token) || overwrite)
        {
            counts[token] = 0;
        }
        return counts[token];
    }

    public int GetCounterValue(string token)
    {
        if (!counts.ContainsKey(token)) return 0;
        return counts[token];
    }

    public object CaptureState()
    {
        return counts;
    }

    public void RestoreState(object state)
    {
        counts = (Dictionary<string, int>)state;
    }
    
}

After work, I’ll get into the specifics of how I would apply this to the Quest system, and as a bonus, how to use this as an achievement counter.

1 Like

Hello Brian, thanks for getting the ball rolling. I look forward to your continued input and evolution on this issue. I am currently at work seeing patients so I won’t be able to fully digest this or try it’s application until later tonight but I hope and am eager to see further development on your part regarding this. Thanks again for your time and help!!

Kindly,
A

1 Like

Not a problem, just wanted to get something down before I headed to work. Still there on lunch.

I think the next step is setting the AchievementCounter up as an IPredicateEvaluator…

public bool? Evaluate(EPredicate predicate, string[] params)
{
     if(predicate==EPredicate.HasKilled)
     {
            if(int.TryParse(params[1], out int required)
            {
                   RegisterCounter(params[0]);
                   return GetCounterValue(params[0]>=required);
            }
     }
     return null;
}

More to follow.

The next step is going to be a specialized QuestCompletion. I’ll write that out when I get home.

Hey Brian, thanks again. I’ve gone ahead and implemented the above script. Ill try and tangle a bit with it but still looking forward to your input!

Kindly,
A

Ok, here’s the revised mostly final scripts, and what I’ve come up with as a path to the HasKilled Condition:

First is the AchievementCounter class:

using System.Collections.Generic;
using GameDevTV.Saving;
using GameDevTV.Utils;
using Newtonsoft.Json.Linq;
using UnityEngine;

namespace RPG.Quests
{
    public class AchievementCounter : MonoBehaviour, ISaveable, IPredicateEvaluator, IJsonSaveable
    {
        private Dictionary<string, int> counts = new Dictionary<string, int>();

        public event System.Action onCountChanged;
    
        public int AddToCount(string token, int amount, bool onlyIfExists=false)
        {
            if (!counts.ContainsKey(token))
            {
                if (onlyIfExists) return 0;
                counts[token] = amount;
                onCountChanged?.Invoke();
                return amount;
            }
            counts[token] += amount;
            onCountChanged?.Invoke();
            return counts[token];
        }

        public int RegisterCounter(string token, bool overwrite=false)
        {
            if (!counts.ContainsKey(token) || overwrite)
            {
                counts[token] = 0;
                onCountChanged?.Invoke();
            }
            return counts[token];
        }

        public int GetCounterValue(string token)
        {
            if (!counts.ContainsKey(token)) return 0;
            return counts[token];
        }

        public object CaptureState()
        {
            return counts;
        }

        public void RestoreState(object state)
        {
            counts = (Dictionary<string, int>)state;
            onCountChanged?.Invoke();
        }

        public bool? Evaluate(EPredicate predicate, string[] parameters)
        {
            if (predicate == EPredicate.HasKilled)
            {
                if (int.TryParse(parameters[1], out int intParameter))
                {
                    RegisterCounter(parameters[0]);
                    return counts[parameters[0]] >= intParameter;
                }
                return false;
            }
            return null;
        }

        public JToken CaptureAsJToken()
        {
            JObject state = new JObject();
            IDictionary<string, JToken> stateDict = state;
            foreach (KeyValuePair<string,int> keyValuePair in counts)
            {
                stateDict[keyValuePair.Key] = JToken.FromObject(keyValuePair.Value);
            }
            return state;
        }

        public void RestoreFromJToken(JToken state)
        {
            if (state is JObject stateObject)
            {
                IDictionary<string, JToken> stateDict = stateObject;
                counts.Clear();
                foreach (KeyValuePair<string,JToken> keyValuePair in stateDict)
                {
                    counts[keyValuePair.Key] = keyValuePair.Value.ToObject<int>();
                }
                onCountChanged?.Invoke();
            }
        }
    }
}

This should be fairly straightforward, but I’ll give a bit of insight where needed. This will be a component attached to the player. It will implement ISaveable (or IJsonSaveable, depending on your needs, if you haven’t implemented my Json Saving System, just delete the references).

We’ll be using a string based dictionary that simply stores a token and a count.
The primary method, AddToCount, takes in a token and an amount and increases the appropriate counter in the dictionary. It also invokes the onCountChanged event, which is how our QuestList will eventually know about the changes in counts so that it can test the condition. The method also contains an optional bool which determines if the count should only be updated if the token already exists in the Dictionary. This is how we’ll set our Quest up to only start counting once we’ve accepted the quest. I’ll get to that shortly. The method returns the current count, in case it’s useful in some other section of code.

The RegisterCounter method creates a counter if one doesn’t exist. When we accept a quest that has a HasKilled condition, the condition will automatically call RegisterCounter and subsequent kills for the token will work.

GetCounterValue should be self-explanatory.

The Evaluate method will test for HasKilled, and compare the counter to the required number of kills. Note that we’re applying RegisterCounter() before returning the GetCounterValue. The magic here is that the only time we would be testing this predicate against this token is if there is a reason to do so, i.e. that we have the quest and are testing for it! Supposing that the quest was to kill 10 wolves, you could kill all the wolves you wanted before accepting the quest, but the counter wouldn’t increase. Once you’ve accepted the quest, however, then the system will start testing to see if you’ve killed wolves, and then it will start counting every wolf death.

That brings us to the “How exactly do we tell the system we killed a wolf” section of the game.

Next up is the DeathCounter. I set this up to automagically take care of sending the death notification to the counter system.

using RPG.Attributes;
using UnityEngine;

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

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

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

This component will go on the enemies you want to kill to advance the counters. The identifier is the token you will use in the HasKilled condition. You can use this identifier on multiple characters, so quests like Kill Wolves are possible.

The component first locates the AchievementCounter on the player and caches the reference. Then it hooks itself into Health’s onDie method. The AddToCount() method then informs the AchievementCounter to increase the count, but only if the token is initialized.

Once you’ve done this, QuestStatus will pick up on anychanges because of the use of the CompleteObjectivesByPredicates(); in Update().

2 Likes

Hey brian!! Thank you SOO much for this! I’m at work right now again but I can’t wait to get home later tonight and implement it to see how it works!! I’ll definitely keep you posted.

I have another side question if you don’t mind , figured I’d pick your brain. If when the player completes the quest, I want Him to play an animation or perhaps receive a trait point or maybe gain some experience or get gold, is it easy to modify our rewards class to do this? Right now it only gives items.

Getting gold is easy, if you’ve implemented the Shops and Abilities, you should already have the IItemStore interface method on TraitStore that consumes any coin drops… Do the same thing with experience, and make experience drops.

Traits are a bit trickier, as the Trait store will need to have something like an int aquiredTraits that it adds to the trait points it gets from BaseStats. That number will need to be stored along with the rest of the trait points… I would probably do this by adding a Trait to the enum… perhaps Trait.Rewards. then the same IItemStore interface could be used to add to the Trait.Rewards. When Trait calculates how many rewards are available, it adds Trait.Rewards to the calculation.

public class AwardedTrait : InventoryItem
{
}

Then in TraitStore’s AddItems method:

public int AddItems(InventoryItem item, int number)
{
      if(item is AwardedTrait awardedTrait)
      {
           if(assignedPoints.ContainsKey(Trait.Rewards))
           {
                assignedPoints[Trait.Rewards] = number;
           } else
           { 
                assignedPoints[Trait.Rewards]+=number;
           }
          
          return number;
     }
     return 0;
}

Then in GetAssignablePoints()

        public int GetAssignablePoints()
        {
            assignedPoints.TryGetValue(Trait.Rewards, out int rewards);
            return (int)GetComponent<BaseStats>().GetStat(Stat.TotalTraitPoints)+rewards;
        }

The saving system should manage saving the rewarded traits as it’s just a Dictionary entry.

The last one, playing an animation… that may be a good candidate for the Ability Store to implement the interface… Consider this modification to Ability.cs:

[SerializeField] bool instantEffect=false;
public bool InstantEffect=>instantEffect;

Then set the ability up to target self, add no filters, and add a play animation effect. In ActionStore, implement the IItemStore interface

public int AddItems(InventoryItem item, int number)
{
    if(item is Ability ability && ability.InstantEffect)
    {
          ability.Use(gameObject);
          return number;
     }
     return 0;
}

Hello Brian, sorry I dont understand the last bit.

Shouldn’t I use the event “OnCountChanged” and tie the CompleteObejctivesByPredicate function to that on awake instead of placing it in the update section?

You can do that if you wish, but we’re already calling CompleteObjectivesByPredicate in the Update loop. I think Sam did that because most of the existing predicates would require some way of noticing a change before calling CompleteObjectivesByPredicate…
The ideal solution is to remove Update() altogether in QuestList, and in Awake subscribe to all of the relevant OnChange type events in Inventory, Equipment, TraitStore, BaseStats, etc.

Sounds good thank you! I’ve implemented your solution and currently im revamping the quest tooltip for objectives so I can see if I can track the progress and display that. I havn’t had a chance yet to attempt to implement your rewards suggestion. Anyways, all that to say, firstly again, THANK YOU ! And secondly that I MIGHT have a few more questions on implementing rewards but ill let you know if/when I get there! :slight_smile:

Awesome, I can’t wait to hear how it turns out.
I’ve added the brians-tips-and-tricks tag to the post, as this is something I want to easily refer people to. (I’m trying to get all of these specialty topics tagged with this).

1 Like

In my quest list class, I added this little bit to place the token into the achievement counter because I was having issues with my UI updating and realized that the token was never initialized when the player recieved the quest so kills were not updating the objective.

public void AddQuest(Quest quest)
{
if (HasQuest(quest)) return;
QuestStatus newStatus = new QuestStatus(quest);
statuses.Add(newStatus);

        GetComponent<Sounds>().PlayAudioClip(questRecievedSound);

        foreach (Quest.Objective obj in quest.GetObjectives())
        {
            if (obj.GetPredicateConditionType() == PredicateType.HasKilled)
            {
                achievementCounter.RegisterCounter(obj.GetConditionParamater(0), true);
            }
        }
        OnQuestListEvent?.Invoke();
    }

What do you think? I think its the right way or is there a better way?

Hmmm… if the original QuestList.Update() method was doing the work, it should have automagically initialized the counter. There’s nothing wrong with a little brute force, though. :slight_smile:

You can do that if you wish, but we’re already calling CompleteObjectivesByPredicate in the Update loop

CompleteObjectivesByPredicate: where does this come from?

The next step is going to be a specialized QuestCompletion. I’ll write that out when I get home.

Was this shared anywhere?

Hmmm… if the original QuestList.Update() method was doing the work

When was an update method added?

Thanks.

These things were added in the Shops and Abilities course to extend the Conditions and Quests, allowing you to specify a condition to complete a quest.

Thank you again for replying, so to be able to finish this do i have to start that course or is there a way to accomplish before going through shops and abilities?

I think the best way to understand what Sam has done to make the Quest conditions work, would be to go through the Shops and Abilities course. That being said, if you go to the course repo and browse through the files, you’ll find the two methods in QuestList that are added to make Quest Conditions work (as well as the slight change to the Quest itself to allow a condition to be evaluated for completion).

You may also want to take a look at this post, the first part of which will guide you through changing the predicate from a string (easily misspelled) to an enum. To add HasKilled, you just need to add another value to the Enum that post provides.

The rest of the post deals with actually building a property drawer for the condition, so that it will fill in Quest names, objective names, inventory items, etc. That does rely on code from both the Quests course and the Shops and Abilities course.

I’m currently with near to 100 loose ends. I was trying to clear some before going into shops and abilities which will probably add more loose ends…

Yes i have that post and 2 others from the Brians tips & tricks flagged to do.
I’ll have to see the best approach then.
Thank you again for helping!

Best Regards!

Privacy & Terms