How to Make an Adventure Game Like King’s Quest

In this tutorial you will learn how to implement the core functionality of text-based games like King’s Quest I using Unity. By Najmm Shora.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Implementing Command Parsing

Parsing involves extracting the verb and the entities from the command. Refer to the first entity in the specification as the primary entity and the last entity as the secondary entity.

Navigate to RW/Scripts and open CommandParser.cs. Paste the following above the CommandParser class body but within the namespace block:

public struct ParsedCommand
{
    public string verb;
    public string primaryEntity;
    public string secondaryEntity;
}

This struct acts as the container for the data you want to extract from the command.

Now, inside the CommandParser class body paste:

private static readonly string[] Verbs = { "get", "look", "pick", "pull", "push" };
private static readonly string[] Prepositions = { "to", "at", "up", "into", "using" };
private static readonly string[] Articles = { "a", "an", "the" };

These three string array variables have fixed sizes and are configured with the verbs, prepositions and articles that your parser will use as per your BNF specification. The Parse method that you’ll add next will use these.

Now add the following method to the CommandParser class:

//2
public static ParsedCommand Parse(string command)
{
    var pCmd = new ParsedCommand();
    var words = new Queue<string>(command.ToLowerInvariant().
        Split(new[] { ' ' }, System.StringSplitOptions.RemoveEmptyEntries));

    try
    {
        if (Verbs.Contains(words.Peek())) pCmd.verb = words.Dequeue();

        if (Prepositions.Contains(words.Peek())) words.Dequeue();

        if (Articles.Contains(words.Peek())) words.Dequeue();

        pCmd.primaryEntity = words.Dequeue();
        while (!Prepositions.Contains(words.Peek()))
            pCmd.primaryEntity = $"{pCmd.primaryEntity} {words.Dequeue()}";
        words.Dequeue();

        if (Articles.Contains(words.Peek())) words.Dequeue();

        pCmd.secondaryEntity = words.Dequeue();
        while (words.Count > 0)
            pCmd.secondaryEntity = $"{pCmd.secondaryEntity} {words.Dequeue()}";
    }
    catch (System.InvalidOperationException)
    {
        return pCmd;
    }

    return pCmd;
}

//1
public static bool Contains(this string[] array, string element)
{
    return System.Array.IndexOf(array, element) != -1;
}

There is quite a chunky method there, along with a helper extension method. Here’s a breakdown:

Next, you look for and discard articles. Then you go through the words, concatenating them to the pCmd.primaryEntity until you find another preposition. If you find a preposition, you follow the same process and discard it.

Then look for another article and discard that, too. Finally, you dequeue, concatenate and store words inside pCmd.secondaryEntity until there are no words queue items left.

  1. The helper extension method Contains for string arrays returns true if the given string array contains the string element. If not, it returns false.
  2. The Parse method accepts a string command and returns a ParsedCommand struct. This is done as follows:
    • First you define a new ParsedCommand variable pCmd to store your results.
    • Then, after setting all the letters in command to lowercase, it’s split into individual words and stored inside words queue, while making sure any extra whitespaces in command aren’t included in the queue.
    • Then one by one, you look at the word at the top of the words queue. If the word is a verb, it’s dequeued and stored in pCmd.verb. If the word is a preposition you dequeue it without storing because you don’t need to extract it.

      Next, you look for and discard articles. Then you go through the words, concatenating them to the pCmd.primaryEntity until you find another preposition. If you find a preposition, you follow the same process and discard it.

      Then look for another article and discard that, too. Finally, you dequeue, concatenate and store words inside pCmd.secondaryEntity until there are no words queue items left.

    • You use the try-catch block to catch the InvalidOperationException which is thrown if a Peek operation is carried out on an empty Queue. This can happen if you run out of words.
    • Finally, pCmd is returned.

You can’t test this code yet. Open GameManager.cs from RW/Scripts and paste the following inside the class:

public void ExecuteCommand(string command)
{
    var parsedCommand = CommandParser.Parse(command);
    Debug.Log($"Verb: {parsedCommand.verb}");
    Debug.Log($"Primary: {parsedCommand.primaryEntity}");
    Debug.Log($"Secondary: {parsedCommand.secondaryEntity}");
}

private void Awake()
{
    ExecuteCommand("get to the chopper");
}

In this code you define ExecuteCommand which accepts a string command and then calls it inside Awake. The ExecuteCommand defines parsedCommand and initializes it to the ParsedCommand value returned by passing command to CommandParser.Parse.

For now, ExecuteCommand only logs the values of verb, primaryEntity and secondaryEntity to Unity’s Console.

Save everything, go back to the Unity editor and attach a Game Manager component to the Main Camera. Press Play and check the Console. You’ll see the following output:

Console output
Arnold staring

Now go back to GameManager.cs and replace ExecuteCommand("get to the chopper"); with ExecuteCommand("push the boulder using the wand");. Save and play to get the following output:

Console output with secondary entity

You won’t use the secondary entity in this tutorial. But feel free to use it to improve the project and make it your own later.

Next, you’ll implement interactions using the parsed commands.

Implementing Interactions

Before you implement the interactions, you need to understand the approach taken:

  • The game world has interactable objects, each in a certain state.
  • When you look at these objects in this interactable state, you get some textual response associated with the look action. Refer to that as the look dialogue.
  • This state can be associated with other interactions. These interactions are associated with a dialogue similar to the look dialogue, in addition to the associated in-game actions they trigger.
  • The same interaction can be associated with multiple verbs. For example, pick and get could do the same thing.
  • If an interaction requires the character to be near to the object, there should be a textual response if the character is far away. Call that away dialogue.

Now, brace yourself for a lot of coding. First, open InteractableObject.cs and paste the following at the top of the class:

[System.Serializable]
public struct InteractableState
{
    public string identifier;
    [TextArea] public string lookDialogue;
    public Interaction[] worldInteractions;
}

[System.Serializable]
public struct Interaction
{
    public string[] verbs;
    [TextArea] public string dialogue;
    [TextArea] public string awayDialogue;
    public UnityEngine.Events.UnityEvent actions;
}

This code defines InteractableState and Interaction which represent the approach you saw earlier. The actions variable triggers any in-game actions when it’s invoked. The identifier variable stores the ID for InteractableState so you can map the states using a dictionary.

Now, paste the following inside the InteractableObject class, below the code you just added:

[SerializeField] private float awayMinDistance = 1f;
[SerializeField] private string currentStateKey = "default";
[SerializeField] private InteractableState[] states = null;
[SerializeField] private bool isAvailable = true;
private Dictionary<string, InteractableState> stateDict =
    new Dictionary<string, InteractableState>();

public string LookDialogue => stateDict[currentStateKey].lookDialogue;
public bool IsAvailable { get => isAvailable; set => isAvailable = value; }

public void ChangeState(string newStateId)
{
    currentStateKey = newStateId;
}

public string ExecuteAction(string verb)
{
    return ExecuteActionOnState(stateDict[currentStateKey].worldInteractions, verb);

}

private void Awake()
{
    foreach (var state in states)
    {
        stateDict.Add(state.identifier.Trim(), state);
    }
}

private string ExecuteActionOnState(Interaction[] stateInteractions, string verb)
{
    foreach (var interaction in stateInteractions)
    {
        if (Array.IndexOf(interaction.verbs, verb) != -1)
        {
            if (interaction.awayDialogue != string.Empty
                && Vector2.Distance(
                GameObject.FindGameObjectWithTag("Player").transform.position,
                transform.position) >= awayMinDistance)
            {
                return interaction.awayDialogue;
            }
            else
            {
                interaction.actions?.Invoke();
                return interaction.dialogue;
            }
        }
    }

    return "You can't do that.";
}

That is a bucket load of methods you just added. Here’s a breakdown of the code:

If the awayDialogue is non-empty for the interaction and the distance between the character and the interactable object is greater or equal to awayMinDistance, it returns interaction.awayDialogue.

  • The isAvailable boolean is a simple flag to determine if the object is available for any kind of interaction.
  • Awake populates the stateDict dictionary by mapping the identifier of InteractableState to itself. This is done for all the members of states and helps in quick retrieval.
  • ChangeState is a public helper method to update the value of the currentStateKey.
  • ExecuteAction accepts a verb and passes it on to ExecuteActionOnState along with the worldInteractions of the current state after retrieving the same from the stateDict using the currentStateKey.
  • ExecuteActionOnState finds the interaction with the associated verb. If no such interaction exists, then it returns a default response of "You can't do that.". However if it exists, then it returns the interaction.dialogue.

    If the awayDialogue is non-empty for the interaction and the distance between the character and the interactable object is greater or equal to awayMinDistance, it returns interaction.awayDialogue.

Save everything and head back to GameManager.cs.

Associate multiple name variations for a single interactable object to ensure a good player experience. Paste this struct above the GameManager class body:

[System.Serializable]
public struct InteractableObjectLink
{
    public string[] names;
    public InteractableObject interactableObject;
}

The words contained inside names are associated with the interactableObject.

Now replace all the code inside GameManager class with:

[SerializeField] private InteractableObjectLink[] objectArray = null;
private UIManager uiManager;
private Dictionary<string, InteractableObject> sceneDictionary;

public void ExecuteCommand(string command)
{
    var parsedCommand = CommandParser.Parse(command);

    //1
    if (string.IsNullOrEmpty(parsedCommand.verb))
    {
        uiManager.ShowPopup("Enter a valid command.");
        return;
    }

    if (string.IsNullOrEmpty(parsedCommand.primaryEntity))
    {
        uiManager.ShowPopup("You need to be more specific.");
        return;
    }

    if (sceneDictionary.ContainsKey(parsedCommand.primaryEntity))
    {
        //3
        var sceneObject = sceneDictionary[parsedCommand.primaryEntity];
        if (sceneObject.IsAvailable)
        {
            if (parsedCommand.verb == "look") uiManager.ShowPopup(sceneObject.LookDialogue);
            else uiManager.ShowPopup(sceneObject.ExecuteAction(parsedCommand.verb));
        }
        else
        {
            uiManager.ShowPopup("You can't do that - atleast not now.");
        }
    }
    else
    {
        //2
        uiManager.ShowPopup($"I don't understand '{parsedCommand.primaryEntity}'.");
    }
}

private void Awake()
{
    uiManager = GameManager.FindObjectOfType<UIManager>();
    sceneDictionary = new Dictionary<string, InteractableObject>();
    foreach (var item in objectArray)
    {
        foreach (var name in item.names)
        {
            sceneDictionary.Add(name.ToLowerInvariant().Trim(), item.interactableObject);
        }
    }
}

This code updates the definitions of Awake and ExecuteCommand from earlier and adds some instance variables.

Awake populates the sceneDictionary by iterating through objectArray and mapping the interactableObject of each member to its name. It also initializes uiManager to the UIManager in the scene.

You don’t need to know the inner workings of UIManager. For this tutorial understand that it contains ShowPopup which accepts a string and shows that string in the UI overlay you saw in the beginning.

ExecuteCommand first parses the command then works as follows:

  1. If the parsedCommand has an empty verb or an empty primaryEntity, it calls ShowPopup with some existing text indicating the same to the player.
  2. If the primaryEntity is non-empty, but isn’t present as a key inside sceneDictionary, it calls ShowPopup with some text telling the player the game doesn’t understand that word.
  3. If the InteractableObject is successfully retrieved from the sceneDictionary but isn’t available, ShowPopup is called with text conveying the same. If it’s available then the following happens:
    • If the verb is “look”, ShowPopup is called with the LookDialogue.
    • If the verb isn’t “look”, the ExecuteAction from before is called by passing the parsedCommand.verb to it. The returned string value is passed to ShowPopup.

You can relax now. You’re done coding for this tutorial. Save everything and head back to the Unity Editor.

Now the real fun begins. :]