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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
How to Make an Adventure Game Like King’s Quest
35 mins
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.
- The helper extension method
Contains
for string arrays returns true if the given stringarray
contains the stringelement
. If not, it returns false. - The
Parse
method accepts a stringcommand
and returns aParsedCommand
struct. This is done as follows:- First you define a new
ParsedCommand
variablepCmd
to store your results. - Then, after setting all the letters in
command
to lowercase, it’s split into individual words and stored insidewords
queue, while making sure any extra whitespaces incommand
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 inpCmd.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
insidepCmd.secondaryEntity
until there are nowords
queue items left. - You use the try-catch block to catch the
InvalidOperationException
which is thrown if aPeek
operation is carried out on an emptyQueue
. This can happen if you run out of words. - Finally,
pCmd
is returned.
- First you define a new
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:
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:
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 thestateDict
dictionary by mapping theidentifier
ofInteractableState
to itself. This is done for all the members ofstates
and helps in quick retrieval. -
ChangeState
is a public helper method to update the value of thecurrentStateKey
. -
ExecuteAction
accepts averb
and passes it on toExecuteActionOnState
along with theworldInteractions
of the current state after retrieving the same from thestateDict
using thecurrentStateKey
. -
ExecuteActionOnState
finds theinteraction
with the associatedverb
. If no suchinteraction
exists, then it returns a default response of"You can't do that."
. However if it exists, then it returns theinteraction.dialogue
.If the
awayDialogue
is non-empty for theinteraction
and the distance between the character and the interactable object is greater or equal toawayMinDistance
, it returnsinteraction.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:
- If the
parsedCommand
has an emptyverb
or an emptyprimaryEntity
, it callsShowPopup
with some existing text indicating the same to the player. - If the
primaryEntity
is non-empty, but isn’t present as a key insidesceneDictionary
, it callsShowPopup
with some text telling the player the game doesn’t understand that word. - If the
InteractableObject
is successfully retrieved from thesceneDictionary
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 theLookDialogue
. - If the verb isn’t “look”, the
ExecuteAction
from before is called by passing theparsedCommand.verb
to it. The returned string value is passed toShowPopup
.
- If the verb is “look”,
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. :]