Chapter 11
Conversations/Scripting

When a player starts up an RPG, he expects a vibrant, living, breathing world populated by people and creatures that he can interact with. These interactions could include holding a conversation with an NPC or fighting a monster. If a character were to walk up to a bartender, he would expect to be able to order a drink and get the latest information about what has been happening in the area. The same holds true for being able to interact with a blacksmith to repair equipment, a shopkeeper to buy supplies, or any other NPC. If the person just stands there when the character tries to talk to him, it becomes obvious that the NPC isn’t a person at all, but just a graphic being drawn on the screen. This breaks the immersion a player expects from an RPG. Fortunately, with scripting it’s possible to simulate living beings to some degree. You won’t be able to discuss philosophy or politics with people or have a creature that is very smart in its combat tactics, but you’ll be able to have a degree of interaction, which goes a long way in making a game interesting.

In this chapter, we’ll construct and discuss a conversation editor and the associated classes. Conversation scripts will allow the character to talk to NPCs. This is normally done so that quests can be assigned, but they can also be used just to make an NPC react to the character. Figure 11-1 shows the completed editor:

Figure 11-1

The Conversation Class

As with most things, we start with a class to encapsulate the data we’ll be using for our conversations:

Listing 11-1


public class Conversation
{
private int _id;

[NonSerialized]
private int _idCounter = 0;

private List<ConversationNode> _nodes;

[NonSerialized]
private ConversationNode _curNode;

public Conversation()
{
}

public int ID
{
get { return _id; }
set { _id = value; }
}

public ConversationNode CurNode()
{
return _curNode;
}

public List<ConversationNode> Nodes
{
get { return _nodes; }
set { _nodes = value; }
}

public void Initialize()
{
//assume the first node is the start of the conversation
_curNode = _nodes[0];
}

public string GetNodeText()
{
return _curNode.Text;
}

public List<ConversationNode> GetResponses(int id)
{
ConversationNode node = null;

foreach (ConversationNode child in _nodes)
{
if (child.ID == id)
{
node = child;
break;
}
if (child.Responses.Count > 0)
{
node = FindNodeByID(child, id);
if (node != null)
break;
}
}

return node.Responses;
}

public void SelectNode(int id)
{
foreach (ConversationNode child in _nodes)
{
if (child.ID == id)
{
_curNode = child;
break;
}
if (child.Responses.Count > 0)
{
_curNode = FindNodeByID(child, id);
if (_curNode != null)
break;
}
}
}

public void SelectResponse(int id)
{
//figure out which node should be the current one
foreach (ConversationNode child in _curNode.Responses)
{
if (child.ID == id)
{
_curNode = child;
break;
}
}
}

public void AddNode(ConversationNode node, int parentNodeID)
{
//find the node
foreach(ConversationNode child in _nodes)
{
if (child.ID == parentNodeID)
{
child.Responses.Add(node);
break;
}

ConversationNode parent = FindNodeByID(child, parentNodeID);
if (parent != null)
{
parent.Responses.Add(node);
break;
}
}
}

public int AddNode(string text, FunctionType functionType,
string functionText, CaseType caseType, string[] paramText)
{
_idCounter++;

ConversationNode node = new ConversationNode(_idCounter);

node.Text = text;
node.NodeFunction = functionType;
node.NodeCaseType = caseType;
node.FunctionName = functionText;
node.FunctionParams = paramText;

_nodes.Add(node);

return _idCounter;
}

public int AddNode(int parentNodeID, string text,
FunctionType functionType, string functionText,
CaseType caseType, string[] paramText)
{
_idCounter++;

ConversationNode node = new ConversationNode(_idCounter);

node.Text = text;
node.NodeFunction = functionType;
node.NodeCaseType = caseType;
node.FunctionName = functionText;
node.FunctionParams = paramText;

AddNode(node, parentNodeID);

return _idCounter;
}

ConversationNode FindNodeByID(ConversationNode node, int id)
{
ConversationNode parent = null;

foreach (ConversationNode child in node.Responses)
{
if (child.ID == id)
{
parent = child;
break;
}

if (child.Responses.Count > 0)
{
parent = FindNodeByID(child, id);
if (parent != null)
break;
}
}

return parent;
}

public bool CurNodeHasPreFunction()
{
return _curNode.NodeFunction == FunctionType.PreFunction;
}

public string GetPreFunction()
{
if (CurNodeHasPreFunction())
return _curNode.FunctionName;
else
return "";
}

public bool CurNodeHasPostFunction()
{
return _curNode.NodeFunction == FunctionType.PostFunction;
}

public string GetPostFunction()
{
if (CurNodeHasPostFunction())
return _curNode.FunctionName;
else
return "";
}
}

Each conversation will have a unique ID that can be attached to an entity to allow that entity to start the conversation when the player interacts with him. An entity could have more than one conversation attached to him for different things, such as multiple quests. You’d have to write logic to determine which quest to offer, but that’s easy enough, depending on the quests. If all the conversations are for quests and the character is qualified to undertake all of them, simply generate a random number. If you want to get more complicated, such as offering different quests based on the character (a different quest depending on the character’s class, for example), you’d probably want to write a manager of some sort that knows what kinds of quests the NPC has to offer and what their requirements are. For our purposes though, such logic isn’t really related to a conversation itself, so we’re not going to get into that.

A conversation is basically just a hierarchy of responses based on certain conditions. Those conditions are wrapped up in the ConversationNode class:

Listing 11-2


public class ConversationNode
{
private int _id;
private string _text;
private FunctionType _functionType;
private string _functionName;
private string[] _functionParams;
private CaseType _case;

private List<ConversationNode> _responses;

public ConversationNode()
{

}

public ConversationNode(int id)
{
_id = id;
_responses = new List<ConversationNode>();
}

public int ID
{
get { return _id; }
set { }
}

public string Text
{
get { return _text; }
set { if (!string.IsNullOrEmpty(value)) _text = value; }
}

public FunctionType NodeFunction
{
get { return _functionType; }
set { _functionType = value; }
}

public string FunctionName
{
get {return _functionName;}
set
{
if (_functionType != FunctionType.FunctionNone)
if (!string.IsNullOrEmpty(value))
_functionName = value;
}
}

public string[] FunctionParams
{
get { return _functionParams; }
set
{
if (_functionType != FunctionType.FunctionNone)
if (value != null)
_functionParams = value;
}
}

public CaseType NodeCaseType
{
get { return _case; }
set { _case = value; }
}

public List<ConversationNode> Responses
{
get { return _responses; }
set { _responses = value; }
}

public void AddResponse(ConversationNode node)
{
_responses.Add(node);
}

public void RemoveResponse(int index)
{
_responses.RemoveAt(index);
}

public void UpdateResponse(int index, ConversationNode node)
{
_responses[index] = node;
}
}

Each node in the conversation has its own ID, mainly for determining where in the conversation the two entities are currently or previously (in the case of a conversation spanning completing a quest, which we’ll get to in Chapter 13). The text is what’s displayed to the player. The three function x properties are used to determine what course the conversation takes. The same conversation could happen more than once and the conditions are used to keep the conversation from being repeated exactly the same each time or to keep the NPC from giving incorrect responses.

The _functionType property can have one of three values:

Listing 11-3


public enum FunctionType
{
FunctionNone,
PreFunction,
PostFunction
}

A node in the conversation can be set to have a check done before being displayed, allowing you to filter responses based on selected criteria (a PreFunction) or to have a check done if the player selects the response (a PostFunction).

The _functionName property determines what function is called. Currently, this is somewhat hard-coded to be one of the following:

Listing 11-4


public enum ConversationFunctions
{
FirstTime,
HasQuest,
AssignQuest,
QuestAssigned,
CheckForQuestCompletion,
CompleteQuest
}

Here’s a summary of what each of these functions does:

FirstTime — This function is called to determine if the current interaction between the NPC and the character is their first ever interaction. This function will always be used as a PreFunction. Most conversations, at least the ones that involve quests, will have the function called in the first node of the conversation. In the editor screenshot shown in Figure 11-1, the node with the text “Hello” uses this function. If the interaction is the first between the two, this is the node that’s displayed. The check is a simple one:

Listing 11-5


public bool FirstTime(int id)
{
return HasPlayerTalkedToNPC(id);
}

public bool HasPlayerTalkedToNPC(int id)
{
return _NPCsTalkedTo.IndexOf(id) != -1;
}

_NPCsTalkedTo is a List<int> in the entity class. After every conversation, the NPC’s ID member is added to the list if it’s not already there.

HasQuest — Continuing along the “Hello” node, if the player selects the response “Do you have a quest for me?” this function is called since that response has a PostFunction that calls this function. The NPC checks to see if it has any quests that the character is qualified to undertake, and depending on the return value of the function, displays one of the two responses. Since we’re not doing any checking of this type, the function simply checks to see if the NPC has at least one quest:

Listing 11-6


public bool HasQuest()
{
return _questIDs.Count > 0;
}

_questIDs is another List<int> that provides indexes into the global list of quests that would be created using the Quest Editor from the previous chapter.

AssignQuest — Almost self-explanatory. If the NPC has a quest and the player agrees to undertake it, this function is called. It’s passed a couple of parameters to make sure the quest can be kept track of:

Listing 11-7


public void AssignQuest(int questID, int npcID, long startTime)
{
AssignedQuest quest = new AssignedQuest();

quest.QuestID = questID;
quest.CurStep = 1;
quest.TimeStepStarted = startTime;
quest.TimeQuestStarted = startTime;
quest.QuestGiverID= npcID.ToString();
quest.QuestFinished = false;

_quests.Add(quest);
}

QuestAssigned — This function checks to see if the quest associated with the conversation has already been assigned to the character:

Listing 11-8


public bool QuestAssigned(int id)
{
bool ret = false;

foreach (AssignedQuest quest in _quests)
{
if (quest.QuestID == id)
{
ret = true;
break;
}
}

return ret;
}

CheckForQuestCompletion — This function checks to see if the quest associated with the conversation has been completed by the character so that the NPC can give the character the reward for the quest. It looks almost exactly like the QuestAssigned function, except it has two additional checks:

Listing 11-9


public bool CheckForQuestCompletion(int id)
{
bool ret = false;

foreach (AssignedQuest quest in _quests)
{
if (quest.QuestID == id &&
quest.CurStep == GlobalData.Quests[id].Steps.Count-1 &&
quest.TimeStepFinished > 0)
{
ret = true;
break;
}
}

return ret;
}

CompleteQuest — This function is called if the CheckForQuestCompletion function returns true. It marks the quest as completed and puts the quest reward in the character’s inventory:

Listing 11-10


public void CompleteQuest(int id, long timeFinished)
{
foreach (AssignedQuest quest in _quests)
{
if (quest.QuestID == id)
{
quest.TimeQuestFinished = timeFinished;
_inventory.Items.Add(GlobalData.Items[
GlobalData.Quests[id].RewardItemID]);
}
}
}

The value of the _functionParams member is dependent on the type of function the node calls. For the quest-related functions, this is the ID for the quest and possibly other quest data — the NPC or character ID, the ID of the quest reward, etc. For the FirstTime function, it’s the ID of the NPC. It’s up to the developer to know the order and types of data that each function uses. The data is separated by the pipe character (|) as it’s a fairly safe bet that it’s never going to be used in data, unlike the comma, semicolon, or other characters that you normally find in delimited data.

When an NPC asks a question such as “Did you finish the quest?” we can’t just take the player’s word for it that he’s done; we need to check. That’s the purpose of the _case member. It allows us to call a function to check whether or not the player is telling the truth when he responds to a question like this in the positive. In the case of completing a quest, the CheckForQuest-Completion function is called. This can be used for anything, however; you just need to add to the ConversationFunctions enum the appropriate function to be called and add the logic to call it. The CaseType enum has only three values:

Listing 11-11


public enum CaseType
{
CaseNone,
CaseTrue,
CaseFalse
}

It would be easy enough to expand this to allow the selection of one of three or more responses, in which case you could eliminate the enum and simply use an integer if you want to keep the number of responses open-ended. If you decided to limit the number of responses to a set number, you could keep the enum and simply change it to something like:

Listing 11-12


public enum CaseType
{
None,
Response1,
Response2,
Response3,
Response4,
...,
}

You would only do this if you needed to call a function with some of the responses, however. That’s the only reason for needing the CaseType. Otherwise, you would just construct your conversation nodes as normal.

That’s really all there is to the conversation code. We’ll need some code to manage conversations, though, so that we can test them and have them work the same during the test as they will in a game. Let’s take a look at the ConversationManager class that will do that.

Testing the Conversation

Since you don’t want to have to fire up your game every time you create a conversation (at least I don’t), it makes sense to have the ability to abstract the conversation playing code so you can use it anywhere. The code to manage the conversation would just have methods to call to select a response, show the responses for a node, etc. The ConversationManager class does this, and the code for this chapter includes a Windows Form application that uses the class to test out conversations. You just give the form the NPC and player character Entity objects and the Conversation object, and it loads the manager and starts up the conversation. Select the response for each node and it does the necessary checking to show the proper node for each response. For things like checking to see if the player has a quest item, you would have to modify the player data, but since the form uses the LoadEntity function, you simply modify the XML file for the player. In the game, the compiled version of the player file is used with the ContentManager, so you won’t be able to modify the file by hand as you can with the XML version.

Here’s the conversation test form at the start of the conversation we’ve been working on:

Figure 11-2

Here’s the code behind the form. Some of the functionality will be reused in the game’s GUI once it’s created:

Listing 11-13


public partial class ConversationTestForm : Form
{
Conversation _conversation;
Entity _player, _npc;

ConversationManager _manager;

List<ConversationNode> _responses;

public ConversationTestForm(Conversation conversation, Entity npc,
Entity playerCharacter)
{
InitializeComponent();

_conversation = conversation;

_player = playerCharacter;
_npc = npc;

_manager = new ConversationManager(_conversation, _player, _npc);

}

private void btnStart_Click(object sender, EventArgs e)
{
lblTest.Text = _manager.Start();

_responses = _manager.GetResponses();

SetResponses();
}

private void SetResponses()
{
//wipe out controls in group box and recreate
grpResponses.Controls.Clear();

Button btn;

foreach (ConversationNode node in _responses)
{
btn = new Button();
btn.Text = node.Text;
btn.Width = TextRenderer.MeasureText(btn.Text, btn.Font).Width +
20;
btn.Click += new EventHandler(btnResponse_Click);
btn.Tag = node.ID.ToString();
btn.Location = new Point(10, (grpResponses.Controls.Count *
btn.Height) + (15 * grpResponses.Controls.Count) + 15);

grpResponses.Controls.Add(btn);
}

}

private void btnClose_Click(object sender, EventArgs e)
{
this.Close();
}

private void btnResponse_Click(object sender, EventArgs e)
{
_manager.SelectResponse(Convert.ToInt32(((Button)sender).Tag));

List<ConversationNode> responses = _manager.GetResponses();

lblTest.Text = "";

if (_manager.GetCurNode().NodeFunction == FunctionType.PostFunction)
{
switch (_manager.GetCurNode().FunctionName)
{
case "HasQuest":
{
if (_npc.HasQuest())
{
//filter responses
foreach (ConversationNode response in responses)
{
if (response.NodeCaseType == CaseType.CaseTrue)
{
_manager.SelectResponse(response.ID);
lblTest.Text = response.Text;
}
}
}
else
{
foreach (ConversationNode response in responses)
{
if (response.NodeCaseType == CaseType.CaseFalse)
{
_manager.SelectResponse(response.ID);
lblTest.Text = response.Text;
}
}
}

_responses = _manager.GetResponses();

SetResponses();

break;
}
case "AssignQuest":
{
_player.AssignQuest(Convert.ToInt32(
_manager.GetCurNode().FunctionParams[0]),
_npc.Name, 0);

break;
}
case "CheckForQuestCompletion":
{
//filter responses
foreach (ConversationNode response in _responses)
{
if (response.NodeCaseType == CaseType.CaseFalse &&
!_player.CheckForQuestCompletion(
Convert.ToInt32(response.FunctionParams[0])))
{
_manager.SelectResponse(response.ID);
lblTest.Text = response.Text;
}
else if (response.NodeCaseType == CaseType.CaseTrue
&& _player.CheckForQuestCompletion(
Convert.ToInt32(response.FunctionParams[0])))
{
_manager.SelectResponse(response.ID);
lblTest.Text = response.Text;
}
}

responses = _manager.GetResponses();

SetResponses();

break;
}
}
}
else
{
_responses = new List<ConversationNode>();

SetResponses();
}
}
}

The first time you test the conversation, the FirstTime function that is the PreFunction for the first node in the conversation will return true, setting the current node to the first node and displaying the text for the node. The responses for the first node are returned by the call to GetResponses, and buttons are dynamically created and the response text is used to set the button’s Text property. A Click event handler is added to each button in the list of responses to handle the selected response. All the buttons use the same handler. The code in the event uses the sender object to get the selected response.

When you click the Start button, the ConversationManager’s Start method is called to get the starting node. In most conversations this is going to be the first node, but it doesn’t have to be. The responses for that node are then retrieved and used to create the buttons that you use to select a response.

When you click a response, the Tag property of the button is issued to get the selected response and continue the conversation. The selected response is checked to see if there’s a PostFunction attached to it and, if so, calls it to resolve what node to show next, if any.

The ConversationManager is fairly small:

Listing 11-14


public class ConversationManager
{
Conversation _conversation;
Entity _player, _npc;

ConversationNode _curNode;

public ConversationManager(Conversation conversation, Entity player,
Entity npc)
{
_conversation = conversation;
_player = player;
_npc = npc;
}

public string Start()
{
string text = "";

_conversation.Initialize();

_curNode = _conversation.GetCurNode();

while (text == "")
{
text = CheckForStartNode();

if (text == "")
_curNode = _conversation.GetNextSibling();
}

return text;
}

private string CheckForStartNode()
{
string text = "";

//Make sure initial node is the correct one
//check for PreFunction and verify conditions
if (_curNode.NodeFunction == FunctionType.PreFunction)
{
switch (_curNode.FunctionName)
{
case "FirstTime":
{
if (!_player.HasPlayerTalkedToNPC(_npc.Name))
text = _curNode.Text;

break;
}
case "QuestAssigned":
{
if (_player.QuestAssigned(Convert.ToInt32(
_curNode.FunctionParams[0])))
text = _curNode.Text;

break;
}
case "CheckForQuestCompletion":
{
if (_player.CheckForQuestCompletion(Convert.ToInt32(
_curNode.FunctionParams[0])))
text = _curNode.Text;

break;
}
}

}
else
text = _curNode.Text;

return text;
}

public List<ConversationNode> GetResponses()
{
List<ConversationNode> responses = new List<ConversationNode>();

responses = _conversation.GetResponses(_curNode.ID);

//filter responses
foreach (ConversationNode response in responses)
{
if (response.NodeFunction == FunctionType.PreFunction)
{
switch (response.FunctionName)
{
case "FirstTime":
{
if (_player.HasPlayerTalkedToNPC(_npc.Name))
responses.Remove(response);

break;
}
case "QuestAssigned":
{
if (!_player.QuestAssigned(Convert.ToInt32(
response.FunctionParams[0])))
responses.Remove(response);

break;
}
}
}
}

return responses;
}

public void SelectResponse(int id)
{
_conversation.SelectNode(id);
_curNode = _conversation.GetCurNode();
}

public ConversationNode GetCurNode()
{
return _curNode;
}
}

The biggest chunk of code is that which gets the responses for the current node (the current node is what the NPC is saying). It has to resolve any PreFunction criteria to determine what responses are valid to return. The PreFunction criteria is also used to determine the initial response for the NPC at the beginning of the conversation.

Summary

We’re now into the home stretch. With everything that’s been discussed to this point, you have all the knowledge you need to put together a basic single-player RPG. What’s that you say? What if you want to create an RPG you can play with a friend? Hmmm, good question. Tell you what — turn to the next chapter and we’ll see what kind of information we can dig up on the issues involved with creating multiplayer RPGs and what the XNA Framework and XNA Game Studio can do to help you create one.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.138.67.27