Hello, internet!

This time I’ll try to explain how we’ve come to make our dialogue and saving system from scratch! This had to be made for our For Fox Sake game which was a submission for Community Game Jam!

For Fox Sake!

Not everything is as it seems at first glance.
Some problems are just in your head and you play like they are really there.
Will you find all the things this little valley prepared for you?
Will you succeed on an adventure your grandpa send you on

Before reading any further, I encourage you to try out the game! I’ll give you a small tip: Game jam theme was “The Game is a liar”. So just keep it in mind while playing. Ok, go, and come back when you are finished!

Brainstorming

Oh, there you are. We hope you like it! Let’s continue.

We’ve first brainstormed about ideas of what we want to do in the game and how much control we give to the player. We wanted players to interact with many objects and NPCs presented in the world. Pretty fast we came up with restrictions that would enable us to actually finish the game in time! 

  • Dialogue was linear
  • Each interaction was only triggering one line of dialogue. It made it more like a reaction to interaction, not a dialogue, but it fit our needs more than enough!
  • All items in the game are dialogue Actors, which means that Cheese is a dialogue Actor too!

Some objects required the player to hold a specific item in order to trigger specific dialogue, like Axe to chop the Log blocking the path, or Coin to buy the Axe from happy ShopKeeper in the village.
If the player didn’t have a specific item in hand while interacting with an object, he would get a dialogue that would hint about what needs to be done in order to progress.
In the end, we wanted to make our world a little bit alive and memorable, which means for example that after finishing a quest for our cute hedgehog, you still can talk to him after and he remembers you!

Example

Ok, so let’s tackle the problem and see what we came up with!

We made some use of Unity ScriptableObjects and made one instance for every:

  • NPC – contained Name, Image to be displayed in dialogue bubble and a sound file to be played when NPC talks
  • Pickable Item – containing Image and item Tag, which we used to reference the item in all our systems. Although, when I now look at it, we could easily just reference the scriptable object itself! #TimeConstraints
  • DialogueList – every interactable item had his own DialogueList which was queried against to see which dialogue to play when the player interacts with it. (so all NPCs, PickableItems but also elements in the world, like AreaTriggers, Signposts, etc. had such list)

Dialogue List

Since NPC and PickableItem objects are pretty simple, I’ll skip them and focus on DialogueList. Here’s an example of such an asset attached to Hedgehog NPC. 

Let’s analyze it. (You can see that inspector UI is quite different from the default one. We’ve used OdinInspector for making it more appealing and easier to work with, but you could do it all without it!)

As you can see, DialogueList has… ekhm. A Collection of Dialogues! Shocking! And this particular one has 3 of them! Let’s focus on the first one.

So, every “dialogue” has:

  • NPC Character – holds NPC which says it. This is important because the Dialogue list itself is sitting on HedgehogNPC, but maybe Fox should say this line. 
  • Line – an actual line that is displayed on the screen
  • Dialogue Variables – An EnumFlag field, which the only purpose is to hide fields below from inspector when they are not used, to reduce clutter
  • Required Item – If the dialogue requires the player to hold an item in order to be played. I this case, the player needs to hold an Apple for this dialogue to play
  • Remove Item – If the dialogue removes the item after being triggered. In this case, you are supposed to give an Apple to Hedgehog, which should remove it from the player’s inventory
  • RequirementVariables – variables (from our VariableSystem, which I’ll talk about in a sec) which must be set for this dialogue to trigger. So this dialogue would not play unless “hedgehog_visited” variable has its value equal to ‘1’.
  • SetVariables – variables (from the VariableSystem) which are set to a specific value when this dialogue is played.

Uff, we got it all! It’s pretty straight forward. The important thing is that both items and variable requirements have to be met in order for dialogue to be played!

So, how does it work?

When the player interacts with Hedgehog, system checks which dialogue to play from Hedgehog’s dialogue list. It checks entries one by one and plays a dialogue as soon as he finds the one that meets the requirements, so ORDER MATTERS!

Let’s analyze the whole Hedgehog’s dialogue list and see what happens:

  1. At the beginning of the game,  “hedgehog_visited” and “hedgehog_gave_apple” variables are both set to ‘0’.
  2. If the player interacts with the hedgehog for the first time, the system checks the dialogue list in order:
    1. first dialogue can’t be played, even if the player holds an apple, because “hedgehog_visited” != ‘1’.
    2. It proceeds to check the second dialogue which can be played! “Hedgehog_gave_apple” == ‘0’!
  3. The second dialogue is played by displaying the message “I’m so hungry… If I only had an apple… Well, well, well.”
    This dialogue sets variable “hedgehog_visited” = ‘1’.
  4. If a player now interacts with the hedgehog, he can have 2 outputs of the dialogue:
    1. If he doesn’t hold an apple so the same message will be displayed (the first one fails because the player doesn’t hold the apple) 
    2. And the second one succeeded because “hedgehog_gave_apple” is still ‘0’!
  5. Although, if the player brings an apple to hedgehog the first dialogue would succeed and play the line “I hope this apple has some worms. Oh, they are! Thanks!” setting in meantime 2 variables: “hedgehog_gave_apple” = ‘1’, and “ACH_hedgehog” = ‘1’
  6. Now, interacting with hedgehog would fail first check (there are no more apples in the world), the second check would also fail (“hedgehog_gave_apple” != ‘0’) and the last one would be played: “I’m not hungry anymore. Thank you.”

Thanks to this setup our hedgehog can be responsive and react on things that happened in the game.

The interesting thing you could spot is the variable “ACH_hedgehog”. Setting it would automatically unlock the corresponding achievement! How cool is that? We’ve just connected the achievement system to dialogues by simply making every achievement a variable!

Ok ok, but what exactly is this Variable system everything is magically working with?

I’m glad you asked!

It’s a complex system of variables with implemented Binding, which enables automatic notifications for all subscribers when a variable changes, thus making things like Achievement gain possible and effortless.

Well… not really. I mean it’s cool, but it’s super simple to start and expand upon!

In the end, it’s just a Dictionary<string, int> which holds a KeyValue pair of Key=VariableName and Value=VariableValue.

That’s all, in a separate class, with added serialization and deserialization to make game saves possible.

Anyone could also subscribe for a variable change, which can be done by passing a variableName which interests you, and a method callback to be called when variable changes. 

Changes can be detected whenever someone tries to change some variable through the public Set method, we can check if a variable has changed and if so, raise the event for all registered subscribers!

It enabled us to make every state in the world held in those variables, for example, Cheese item subscribes to variable “Item_CheeseIsOn” which starts with a value of 0. (meaning cheese should be hidden in the game) when you interact with *something specific* in the world, “Item_CheeseIsOn” is set to 1, notifying the Cheese! Thanks to that Cheese can make itself visible and pickupable! And it works with game saves! It’s very powerful! 

Similarly with the achievement system which subscribes for changes to variables which indicate gaining an Achievement!

Thanks for reading, we have plans to cover our work on SignificantOtter game too in future! Stay tuned for that!

Finally some code!

Here’s code for Dialogue class, without public methods and accessors. Please keep in mind that for some visual things to work, you would need an Odin Inspector plugin!

[Serializable]
public class Dialogue
{
    // NPC Character which says the line
    [SerializeField]
    private NPCharacter npcCharacter;
    public NPCharacter NpcCharacter => npcCharacter;
 
    // Dialogue line which will be displayed on screen
    [SerializeField, TextArea(3, 5)]
    private string line;
 
    // Enum flag for easier checking which conditions apply for dialogue if any
    [Flags]
    public enum DialogueVariables
    {
        Item = 1 << 1,
        Requirement = 1 << 2,
        SetAfterDialogue = 1 << 3,
    };
 
    [SerializeField, EnumToggleButtons]
    private DialogueVariables dialogueVariables;
 
    // Odin Inspector properties for hiding not used properties to remove clutter in inspector
    private bool ShowItemThings => (dialogueVariables & DialogueVariables.Item) == DialogueVariables.Item;
    private bool ShowRequiredThings => (dialogueVariables & DialogueVariables.Requirement) == DialogueVariables.Requirement;
    private bool ShowSetThings => (dialogueVariables & DialogueVariables.SetAfterDialogue) == DialogueVariables.SetAfterDialogue;
 
    // Item required to be held in hand in order to start this dialogue
    [GUIColor(0.8f, 0.8f, 1f, 1f)]
    [SerializeField, ShowIf("ShowItemThings")]
    private Item requiredItem;
    [GUIColor(0.8f, 0.8f, 1f, 1f)]
    [SerializeField, ShowIf("ShowItemThings")]
    private bool removeItem;
 
    // Variables required to have desired values to be able to start this dialogue
    [GUIColor(1f, 0.8f, 0.8f, 1f)]
    [SerializeField, ShowIf("ShowRequiredThings")]
    private List<GlobalVariableCompareTemplate> requirementVariables;
 
    // Variables to be set when dialogue starts
    [GUIColor(0.8f, 1f, 0.8f, 1f)]
    [Space]
    [SerializeField, ShowIf("ShowSetThings")]
    private List<GlobalVariableTemplate> setVariables;
 
    // Accessors and other methods omitted
}
using Sirenix.OdinInspector;
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
 
public class QuestVariables : MonoBehaviour
{
    // Singleton
    public static QuestVariables Instance;
 
    // Dictionary holding variables
    [ShowInInspector]
    private Dictionary<string, int> variables = new Dictionary<string, int>();
 
    // Filename for save game
    [SerializeField]
    private string fileName;
 
    // Internal class for storing listener data
    private class VariableChangeListener
    {
        private object listener;
        public object Listener { get; private set; }
        public Action<int> Action { get; private set; }
 
        public VariableChangeListener(object listener, Action<int> action)
        {
            Listener = Listener;
            Action = action;
        }
    }
    // Registered listeners
    private Dictionary<string, List<VariableChangeListener>> listeners = new Dictionary<string, List<VariableChangeListener>>();
 
    private void Awake()
    {
        Instance = this;
        Load();
        DontDestroyOnLoad(this);
    }
 
    private void OnDestroy()
    {
        Save();
    }
 
    // Use to retrieve variable value
    public int GetValue(string key)
    {
        if (!variables.ContainsKey(key))
        {
            variables.Add(key, 0);
            return 0;
        }
        return variables[key];
    }
 
    // Use to set variable value. Notify listeners if new value is set
    public void SetValue(string key, int value)
    {
        if (!variables.ContainsKey(key))
        {
            variables.Add(key, value);
            NotifyListeners(key, value);
        }
        else if (variables[key] != value)
        {
            variables[key] = value;
            NotifyListeners(key, value);
        }
 
        Debug.Log($"QuestVariables::SetValue - {key} = {value}");
    }
 
    // Use to subscribe for variable change
    public int Subscribe(object listener, string key, Action<int> action)
    {
        if (!listeners.ContainsKey(key))
        {
            listeners.Add(key, new List<VariableChangeListener> { new VariableChangeListener(listener, action) });
        }
        else
        {
            listeners[key].Add(new VariableChangeListener(listener, action));
        }
        return GetValue(key);
    }
 
    // Use to unsubscribe for variable change
    public void Unsubscribe(object listener, string key, Action<int> action)
    {
        if (listeners.ContainsKey(key))
        {
            var foundListener = listeners[key].Find(x => x.Listener == listener && x.Action == action);
            listeners[key].Remove(foundListener);
        }
    }
 
    // Notify listeners about variable change
    private void NotifyListeners(string key, int value)
    {
        if (listeners.ContainsKey(key))
        {
            var listenersForKey = listeners[key];
            foreach (var listener in listenersForKey)
            {
                listener.Action?.Invoke(value);
            }
        }
    }
 
    // Save variables in simple custom CSV format
    public void Save()
    {
        var filePath = Path.Combine(Application.persistentDataPath, fileName);
        var serializedValues = new List<string>();
        foreach (var entry in variables)
        {
            serializedValues.Add($"{entry.Key}:{entry.Value}");
        }
        File.WriteAllLines(filePath, serializedValues);
    }
 
    // Load variables
    public void Load()
    {
        var filePath = Path.Combine(Application.persistentDataPath, fileName);
        Debug.Log("Loading from " + filePath);
        if (!File.Exists(filePath))
        {
            return;
        }
 
        var serializedValues = File.ReadAllLines(filePath);
 
        foreach (var value in serializedValues)
        {
            var splitValue = value.Split(':');
 
            SetValue(splitValue[0], int.Parse(splitValue[1]));
        }
    }
}
Categories: For Fox SakeTutorial

Leave a Reply

Your email address will not be published. Required fields are marked *