XNA Journal: Dialogue Version 2
I’ve made some changes to the way my Conversation Engine works. The engine is now more or less final — there are some small tweaks I want to make, but everything is perfectly functional. It now supports saving, loading, and choices, along with a few other nifty changes. First, some shoutouts and credit.
In the new engine, I’m using Mitofaceowo.png for avatars. I downloaded the spritesheet from RPG Maker VX. Special thanks as well to Michael B McLaughlin who gave some great advice on how to use the IntermediateSerializer class.
Alright, so let’s dive in. First of all, since I know everyone loves graphics and hates scrolling, you can find some images after the break. You can also download the source code here.
Yeah, I’m not a designer. But that isn’t so bad, is it? Anyway, a quick rundown of what has changed:
- Support for different Avatar states. For instance, you make one avatar spritesheet that has five different emotions. You reference the same spritesheet for every avatar used for the character, but just change an Enum type that defines what emotion to use. In the images above, the avatars are in the same file. I’ve just switched from Avatar.Happy to Avatar.Normal.
- XNA Content Pipeline support. Instead of the XMLSerializer I used in the last version, I’ve switched to the IntermediateSerializer. This will generate XNA compatible XML, which is then ran through the content pipeline when you publish a game. The result is an XNB binary file, which has a significantly smaller filesize. You can also load the file with the Content.Load method, which is very handy.
- A new choice system. The UI scales automatically based on how many choices there are. I personally don’t think the scaling is great, but you can change that relatively easily. Each choice corresponds to a different file, which is automatically loaded and ran when you select an option.
- Removed various unused methods and properties.
- Moved the Speaker class to a new project, because of IntermediateSerializer requirements.
So let’s get on to the code. I’m going to skip over a lot of the minor changes, and focus first on serialization and then the choices.
#region Saving and Loading Conversations
/// <summary>
/// Saves a Conversation File
/// </summary>
public static void SaveConversation(int id)
{
XmlWriterSettings settings = new XmlWriterSettings();
settings.Indent = true;
using (XmlWriter writer = XmlWriter.Create(id.ToString() + ".xml", settings))
{
IntermediateSerializer.Serialize<List<Speaker>>(writer, ConversationSpeakers, null);
}
}
/// <summary>
/// Loads a Conversation File
/// </summary>
public static void LoadConversation(int id)
{
currentSpeakerIndex = 0;
ConversationSpeakers = null;
ConversationSpeakers = Content.Load<List<Speaker>>(@"Conversations\" + id);
}
#endregion
Remember how long and relatively ugly the old code was? The new stuff is really this simple. In LoadConversation all we have to do is reset the speaker index, and create a new list of Speaker objects. In the SaveConversation method, we use the new serializer to create an XNA compatible XML document that holds the data for our current ConversationSpeakers list.
This in turn requires us to modify the Speaker class. First, you need to create a new XNA 4.0 Library project. All you need to do is stick the existing Speaker class code into it. There have been some modifications to that code, however. The new code supports Choices and Avatar states:
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.GamerServices;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Media;
namespace ConversationEngine
{
public class Speaker
{
#region Declarations
public enum AvatarState { Normal, Surprised, Sad, Angry };
public AvatarState avatarState = AvatarState.Normal;
public int AvatarIndex;
public string Message;
public Dictionary<string, int> Choices = new Dictionary<string, int>();
public bool IsChoice
{
get { return (Choices != null); }
}
#endregion
#region Constructor
/// <summary>
/// Add a new Speaker to a Conversation
/// </summary>
/// <param name="avatar">Avatar Index for Speaker</param>
/// <param name="msg">Speaker's Message</param>
public Speaker(int avatar, string msg, AvatarState state, Dictionary<string, int> choices)
{
AvatarIndex = avatar;
Message = msg;
avatarState = state;
Choices = choices;
}
/// <summary>
/// Needed for Serialization. Not intended for use.
/// </summary>
public Speaker()
{
AvatarIndex = 0;
Message = "";
avatarState = AvatarState.Normal;
Choices = null;
}
#endregion
}
}
Now that we have this in a new project, we need to reference it in our Game and Content projects as well. With that done, the file system should work. Eventually I’ll make a GUI to create conversations with, but until then you’ll have to do it via code. I’ve detailed how to do so in the StartConversation method:
/// <summary>
/// Starts a new Conversation
/// </summary>
/// <param name="conversationID">Conversation ID to use</param>
public static void StartConversation(int conversationID)
{
currentSpeakerIndex = 0;
LoadConversation(conversationID);
// You can create conversations like this. The output will be in the DialogueEngine\DialogueEngine\bin\x86\Debug directory.
//ConversationSpeakers.Add(new Speaker(2, "This is a Test", Speaker.AvatarState.Normal, null));
//ConversationSpeakers.Add(new Speaker(2, "Reid Flamm, Golden Sun is a great game. You really should play the first and second. Like, really. Or Ian will smite you. Alright, now I'm just testing to see how many lines I can get in this dialogue box. Like, really. It's important, ok? Don't hate. I need to figure out when I can artificially break one guy talking into two or three boxes. Cause yeah, that's important. But oh man, how do I manage that with different fonts? That will be an issue. Hrm..... Oh hey, it works. Go figure!", Speaker.AvatarState.Sad, null));
//ConversationSpeakers.Add(new Speaker(2, "Return to the first speaker, and try ending with a preformatted string. And..... end.\n \n -- Ian", Speaker.AvatarState.Surprised, null));
//ConversationSpeakers.Add(new Speaker(2, "This is just a fourth avatar state test.", Speaker.AvatarState.Angry, null));
//ConversationSpeakers.Add(new Speaker(2, "", Speaker.AvatarState.Normal, new Dictionary<string, int>() { { "Test Choice 1", 2 }, { "Test Choice 2", 2 }, { "Test Choice 3", 2 } })); // { Option String, Filename }
//SaveConversation(2); // the number is the file name.
CreateBox(ConversationSpeakers[currentSpeakerIndex].Message,
new Rectangle(100, 200, 600, 150),
new Rectangle(250, 215, 445, 115),
new Rectangle(120, 215, 115, 115),
backgroundImage);
}
So next we need to modify how we handle user input, in order to support choices.
#region Input Handling
/// <summary>
/// Handles User Input during a Conversation
/// </summary>
/// <param name="keyboardState">KeyboardState</param>
private static void HandleKeyboardInput(KeyboardState keyboardState)
{
// Regular Message
if (!ConversationSpeakers[currentSpeakerIndex].IsChoice)
{
if (keyboardState.IsKeyDown(Keys.Space))
{
// Continue to next message
if (currentSpeakerIndex + 1 < ConversationSpeakers.Count)
{
soundEffect.Play();
currentSpeakerIndex++;
revealedMessage = "";
stringIndex = 0;
ConversationSpeakers[currentSpeakerIndex].Message = constrainText(ConversationSpeakers[currentSpeakerIndex].Message);
}
// End Dialogue
else
{
RemoveBox();
}
MessageShown = false;
}
choiceTimer = 0.0f;
}
// A choice
else
{
if (currentChoiceSelection < ConversationSpeakers[currentSpeakerIndex].Choices.Count - 1 && keyboardState.IsKeyDown(Keys.Down))
{
currentChoiceSelection++;
choiceTimer = 0.0f;
}
if (currentChoiceSelection > 0 && keyboardState.IsKeyDown(Keys.Up))
{
currentChoiceSelection--;
choiceTimer = 0.0f;
}
// Handle Selection
if (keyboardState.IsKeyDown(Keys.Space))
{
soundEffect.Play();
LoadConversation(ConversationSpeakers[currentSpeakerIndex].Choices.ElementAt(currentChoiceSelection).Value);
}
}
}
#endregion
First, we throw the old code into a conditional statement that will only activate if the current Speaker node is not a Choice. Our new code will make then move the cursor up or down, with a quick check to make sure there is a choice above or below the currently selected choice when the user hits the arrow keys. If the user hits the spacebar, a choice is selected and the corresponding conversation thread is loaded.
#region Update and Draw
/// <summary>
/// Update the Conversation Message Box
/// </summary>
/// <param name="gameTime">XNA GameTime</param>
public static void Update(GameTime gameTime)
{
if (!Expired)
{
float elapsed = (float)gameTime.ElapsedGameTime.TotalSeconds;
messageTimer += elapsed;
splitIconTimer += elapsed;
choiceTimer += elapsed;
if (!ConversationSpeakers[currentSpeakerIndex].IsChoice && messageTimer >= messageSpeed)
{
// Typewriter Effect
if (stringIndex < ConversationSpeakers[currentSpeakerIndex].Message.Length)
{
revealedMessage += ConversationSpeakers[currentSpeakerIndex].Message[stringIndex];
stringIndex++;
}
// Full message displayed, handle input
else
{
MessageShown = true;
KeyboardState keyboardState = Keyboard.GetState();
HandleKeyboardInput(keyboardState);
}
messageTimer = 0.0f;
}
if (ConversationSpeakers[currentSpeakerIndex].IsChoice && choiceTimer > choiceSpeed)
{
KeyboardState keyboardState = Keyboard.GetState();
HandleKeyboardInput(keyboardState);
}
// Update Continue Reading Icon
if (splitIconTimer >= splitIconSpeed)
{
splitIconOffset = !splitIconOffset;
splitIconTimer = 0.0f;
}
}
}
/// <summary>
/// Draws the Conversation Box to the Screen
/// </summary>
/// <param name="spriteBatch">XNA SpriteBatch</param>
public static void Draw(SpriteBatch spriteBatch)
{
if (!Expired)
{
// Only draw border if specified
if (borderImage != null)
{
spriteBatch.Draw(borderImage,
new Rectangle(boxRectangle.X - borderWidth, boxRectangle.Y - borderWidth, boxRectangle.Width + 2 * borderWidth, boxRectangle.Height + 2 * borderWidth),
borderColor);
}
// Only draw Background if specified
if (backgroundImage != null)
{
spriteBatch.Draw(backgroundImage, boxRectangle, Color.White);
}
// Check to make sure we have the Avatar
if (ConversationSpeakers[currentSpeakerIndex].AvatarIndex < Avatars.Count())
{
Rectangle avatarSource;
// These boxes correspond do the different Avatar States.
switch (ConversationSpeakers[currentSpeakerIndex].avatarState)
{
default:
case Speaker.AvatarState.Normal:
avatarSource = new Rectangle(0, 0, 96, 96);
break;
case Speaker.AvatarState.Surprised:
avatarSource = new Rectangle(96, 0, 96, 96);
break;
case Speaker.AvatarState.Sad:
avatarSource = new Rectangle(0, 96, 96, 96);
break;
case Speaker.AvatarState.Angry:
avatarSource = new Rectangle(96, 96, 96, 96);
break;
}
spriteBatch.Draw(Avatars[ConversationSpeakers[currentSpeakerIndex].AvatarIndex], avatarRectangle, avatarSource, Color.White);
}
// Draw the Message if no Choice specified
if (!ConversationSpeakers[currentSpeakerIndex].IsChoice)
{
spriteBatch.DrawString(spriteFont, revealedMessage, StringPosition, Color.White);
// Check to see if we need to draw the Continue Reading icon
if (MessageShown && currentSpeakerIndex + 1 < ConversationSpeakers.Count())
{
Rectangle splitRectangle = new Rectangle(boxRectangle.X + boxRectangle.Width - 2 * splitIcon.Width + splitIcon.Width / 2,
boxRectangle.Y + boxRectangle.Height - 2 * splitIcon.Height + splitIcon.Height / 2,
splitIcon.Width,
splitIcon.Height);
if (splitIconOffset)
{
splitRectangle.Y += splitIconOffsetValue;
}
spriteBatch.Draw(splitIcon, splitRectangle, Color.White);
}
}
// Draw the Options for a Choice
else
{
int offset = textRectangle.Height / ConversationSpeakers[currentSpeakerIndex].Choices.Count;
int i = 0;
foreach (KeyValuePair<string, int> choice in ConversationSpeakers[currentSpeakerIndex].Choices)
{
spriteBatch.DrawString(spriteFont, choice.Key, new Vector2(StringPosition.X + splitIcon.Height + 10, StringPosition.Y + offset * i), Color.White);
i++;
}
spriteBatch.Draw(splitIcon, new Rectangle((int)StringPosition.X, (int)StringPosition.Y + offset * currentChoiceSelection, splitIcon.Width, splitIcon.Height), new Rectangle(0, 0, splitIcon.Width, splitIcon.Height), Color.White, 0.5f, Vector2.Zero, SpriteEffects.None, 0.0f);
}
}
}
#endregion
Finally, we have the update and draw methods. In Update we throw the old typewriter code into a conditional that only fires if the current node isn’t a choice, and just handle keyboard input if it is a choice. In Draw we’ve actually done a few things. First, we add some code that will use the corresponding rectangle for an AvatarState. It’s kinda inflexible at the moment, and I’ll probably improve on it in the future. Then, the old message and continue icon get thrown into a conditional that fires if the current Speaker node is not a Choice. If there is a Choice, we quickly calculate the offset / padding for the different options (like I said, I don’t really like how it currently scales. I’ll probably be modifying this in the future). The code will then go through each Choice and draw it to the screen.
Conclusion
There you go! A new and improved engine, just like I promised. Yet again, any comments, requests, critiques, suggestions, whatever — I’ll be glad to hear them! Hopefully someone will find this useful. Again, you can download the complete project + source code here. Happy coding!


5 Responses
[...] Update: I’ve updated this code to version 2. You can find the updated engine here. [...]
Was in search for the typewriter effect but you showed quite a few interesting solutions I found useful. Thanks =)
Glad I could help!
Great system, but it uses IntermediateSerializer which is not compatible with the Xbox. I am attempting to build a new system for the Xbox based on your code using XmlSerializer. The problem is that dictonarys are not serializable using XmlSerializer... So I am stuck. Do youhave any suggestions for how to do this?
Hi Morika, sorry for the late reply! I'm not entirely sure myself, but maybe if you reworked the code into a list it would work? You could do List<KeyValue> and make a key value object perhaps?
Leave a Reply