Anatomy of a Conversation to Oblivion
In Consigned to Oblivion a conversation is a linear exchange with no options or alternative lines and basic dialogue follows a prompt -> response format.
Each prompt or response may span multiple dialog boxes, and one sided conversations can occur when the main character does some observational explication.
A line of Speech
Speech
is a simple data container. It holds information about the text string
being spoken and the image sprite
of the speaker. Eventually, I added per-speech configuration data for animation, position, timers and the UI container prefab.
Using Odin Inspector attributes I set up a nice GUI including a foldout that contains a summary of the options set. You how this comes together in the last section.
UI
The UI is quite straight forward, on a basic level it consists of an avatar image of the speaker, and a text box.
This information can be presented and nested in any UI elements as long as the location is exposed to the DialogueBox
monobehaviour on the root parent.
I created all of the graphics except for the avatars. The UI elements consist of a sliced border image and a grayscale mask for the emmission on the edges (using a custom shader).
DialogueBox
is a consumer of the Speech
class, taking in its data/animation properties. It is responsible for presenting the Speech
information given to it, animating (fade in/out etc, from right/left/top/bottom). It is also responsible for its own dismisal either on a timer, or by user input (clicking it).
All processing happens inside coroutines, and the main entry point (below) returns an IEnumerator, this is so the calling class can take care of all execution itself, enumerating and sequencing multiple DialogueBox
's
public IEnumerator DoDialogue(Speech speech, Action callback = null, bool disposeOnEnd = true)
{
// wait until initialized
yield return new WaitUntil(() => isActiveAndEnabled);
// cache data
SetData(speech.Image, speech.Text, speech.Position);
// do tween in
yield return StartCoroutine(Animate(speech.Anim, speech.Position, AnimationDirection.In));
// reset the input based out condition (click)
isDismissed = false;
// Timed dialogue or must be dismissed?
if (speech.IsTimed)
{
yield return new WaitForSeconds(speech.Timer);
}
else
{
yield return new WaitUntil(() => isDismissed);
}
// tween out
yield return StartCoroutine(Animate(speech.Anim, speech.Position, AnimationDirection.Out));
// Execute any callback
if (callback != null)
{
callback.Invoke();
}
// cleanup dialogue
if (disposeOnEnd)
{
Destroy(gameObject);
}
}
Conversation
Given the above, a Conversation
is a simple construct, its a monobehaviour wrapper around a collection of Speech
. Since each Speech
carries its own data and DialogueBox
prefab reference, the majority of the work of this class is simple, iterate over each Speech
instantiating the DialogueBox
prefab and passing in the Speech
data. As with the case above, StartCoroutine
returns a Coroutine
which is a YieldInstruction
, so rather than manually iterating over the enumerator of DoDialogue
, we can just yield as a nested coroutine and wait for it to finish.
/// <summary>
/// A Collection of Speech items that can be executed asynchronously
/// </summary>
public class Conversation : MonoBehaviour
{
/// <summary>
/// True if the conversation blocks player input
/// </summary>
private bool BlockInput = false;
/// <summary>
/// The conversation list
/// </summary>
private List<Speech> conversationList;
/// <summary>
/// Starts the conversation.
/// </summary>
public void StartConversation()
{
StartCoroutine(DoConversation());
}
/// <summary>
/// Does the conversation execution.
/// </summary>
private IEnumerator DoConversation()
{
// disable player controller if required
if (BlockInput)
{
GlobalGameStateManager.Instance.DisablePlayerInput();
}
// iterate over conversations
if (conversationList.Count > 0)
{
for (var i = 0; i < conversationList.Count; i++)
{
var conversation = conversationList[i];
var dialog = Instantiate(conversation.DialoguePrefab);
// run DialogueBox as nested coroutine
yield return StartCoroutine(dialog.DoDialogue(conversation));
}
}
// return player input
if (BlockInput)
{
GlobalGameStateManager.Instance.EnablePlayerInput();
}
}
}
With all the Editor GUI tweaks, a Conversation
looks like this in the editor:
In partnership with the interaction/trigger systems I built, any conversation can be created from any interactable event/trigger/collision/action even if the conversation is contained within another gameobject. My team have leveraged this well, naturally creating conversations and storing them stored to other objects in the scene hierarchy for ease of editing (under a conversation container object), even if they are activated from some deeply nested gameobject.
Final Thoughts
I would have liked the game to leverage conversation trees and decisions, this would have made each playthough slightly diferent in terms of interaction between characters. Not only that, the extra interaction and investment with the story would have drawn the player into the game more.
The final positioning of the dialogue boxes is flawed, in that, conversations that happen between characters have the player read and then click on oposite sides of the screen. This is tiring, it would have been better if all conversations ended up with the text box centered at the bottom/top of the screen, and the avatar offset to the left/right. This way you know who is talking, but interation with the conversation is not jarring.
Finally, I would have liked to bring ScriptableObject
to this, it is a perfect application, Speech
is a self contained data structure requiring no outside references to function. Having these as editable assets would have been much better, it would have allowed for easier reuse of repeated text, and could have even enabled random dialogue selection for cirtain events.