Dialogue authoring
Status: experimental.
What it is
A conversation is a set of lines that a character speaks, and choices the player picks. This API lets your mod write its own conversations while the game runs and start them like any conversation from the game. It can also add a new answer to a conversation the game already has, and hang a whole menu of your own lines under a line of a game character. It can change a line’s text each time it shows, and show a plain block of narration text on a black screen.
An NPC line is spoken by the character. A player line is a choice the player picks. The framework does not write voice, camera cuts or other effects, so a conversation shows with the game’s normal look. It builds on the Dialogue part of the Story API, which starts and stops conversations.
When to use it
- Give a character of your own mod something to say.
- Add a new answer for the player to a game character’s conversation, for example a new shop question.
- Add a menu (such as “Membership”) under a line of an existing character, with its own replies.
- Show a short piece of narration on a black screen, such as “Three days later”.
Quick example
This mod adds a small conversation as soon as the game is ready, and logs the title that the game knows it by. You then start it with Dialogue.Start, as the next section shows.
using ObenseuerCore;
[ModInfo("example.greeter", "Greeter", "0.1.0")]
[ModDependency("obenseuer.moddingframework", "0.11.0")]
public sealed class Greeter : FrameworkMod
{
protected override void OnLoad()
{
Events.WhenReady(() =>
{
ConversationDefinition talk = new ConversationDefinition { Key = "greeting" };
talk.Lines.Add(new ConversationLine { Key = "hello", Text = "Back again?", Next = new[] { "ask", "leave" } });
talk.Lines.Add(new ConversationLine { Key = "ask", Player = true, Text = "Any work for me?", Next = new[] { "work" } });
talk.Lines.Add(new ConversationLine { Key = "leave", Player = true, Text = "Not now." });
talk.Lines.Add(new ConversationLine { Key = "work", Text = "Check the cellar.", Script = "Variable[\"mymod_cellar\"] = true" });
ConversationRefusal refusal;
string title = DialogueAuthoring.Add(Mod.Id, talk, out refusal);
Log.Info(title == null ? "The conversation was refused: " + refusal : "Added " + title);
});
}
}
The first line, “Back again?”, is spoken by the character. It offers two choices, “Any work for me?” and “Not now.”, and a line with no Next ends the conversation. The Script on the last line sets a conversation variable when the player reaches it. Mod.Id is the id you gave in [ModInfo]. See Using events for more on WhenReady.
Addreturns the title, ornullwith the reason inrefusal.NoPlayerActormeans the game’s database has no actor marked as the player.Failedmeans a game call threw or the database did not take the conversation.ReservedKeymeans the key isnarration, whichDialogue.Narrateuses.Removeremoves one of your conversations and returnsfalsefor another mod’s, an unknown one, one that is running now, or a branch’s conversation (useRemoveBranchfor those).Clearremoves all of yours that are not running, and the branches your mod added withDialogue.AddBranch, and returns how many.
Complete example
This mod adds its own conversation, adds a choice and a menu under one of its lines, shows a line whose text changes, and starts the conversation with an NPC. It needs no game conversation title, because the choice and the menu go under a line of the mod’s own conversation.
using ObenseuerCore;
using UnityEngine;
[ModInfo("example.dockgreeter", "Dock Greeter", "0.1.0")]
[ModDependency("obenseuer.moddingframework", "0.11.0")]
public sealed class DockGreeter : FrameworkMod
{
// The name of the NPC that starts the conversation. Use the name of a character in your save.
private const string TalkerName = "Replace with an NPC name";
private const string Greeting = "greeting";
private ModKeybind talkKey;
protected override void OnLoad()
{
talkKey = Mod.Key("Talk", "Start the greeting", KeyCode.F6);
Events.WhenReady(AddConversation);
}
private void AddConversation()
{
string title = DialogueAuthoring.TitleOf(Mod.Id, Greeting);
if (Dialogue.Exists(title)) return;
ConversationDefinition talk = new ConversationDefinition { Key = Greeting };
talk.Lines.Add(new ConversationLine { Key = "hello", Text = "Back again?", Next = new[] { "ask", "leave" } });
talk.Lines.Add(new ConversationLine { Key = "ask", Player = true, Text = "Any work for me?", Next = new[] { "work" } });
talk.Lines.Add(new ConversationLine { Key = "leave", Player = true, Text = "Not now." });
talk.Lines.Add(new ConversationLine
{
Key = "work",
Text = "Check the cellar.",
TextCallback = () => "Check the cellar. You owe " + Dialogue.GetVariableNumber("dock_owed", 0f) + " coins.",
Script = "Variable[\"dock_cellar\"] = true"
});
ConversationRefusal refusal;
string added = DialogueAuthoring.Add(Mod.Id, talk, out refusal);
if (added == null)
{
Log.Warn("The conversation was refused: " + refusal);
return;
}
if (Dialogue.AddResponse(Mod.Id, added, "Back again?", "Do you have any rope?", "Second shelf, by the door.", null, () => Dialogue.SetVariable("dock_rope_asked", true)) == null)
Log.Warn("The rope answer was refused, so the anchor text may not match");
ConversationDefinition membership = new ConversationDefinition { Key = "membership" };
membership.Lines.Add(new ConversationLine { Key = "ask", Player = true, Text = "Membership", Next = new[] { "status" } });
membership.Lines.Add(new ConversationLine { Key = "status", Text = "Your card is in order.", Next = new[] { "renew", "back" } });
membership.Lines.Add(new ConversationLine { Key = "renew", Player = true, Text = "Renew for 30 days.", Script = "Variable[\"dock_renew\"] = true" });
membership.Lines.Add(new ConversationLine { Key = "back", Player = true, Text = "Goodbye." });
if (Dialogue.AddBranch(Mod.Id, added, "Back again?", membership) == null)
Log.Warn("The membership menu was refused");
}
private void Update()
{
if (UiState.IsMenuOpen || UiState.IsPaused || UiState.IsInputBlocked) return;
if (talkKey == null || !talkKey.Down()) return;
NpcInfo npc = Npcs.Find(TalkerName);
if (npc == null || !NpcActions.StartDialogue(npc.Id, DialogueAuthoring.TitleOf(Mod.Id, Greeting)))
Log.Warn("The greeting did not start. Check TalkerName and that the NPC is in the scene.");
}
}
The example adds the conversation the first time a save loads, or a new game starts, in a game session. DialogueAuthoring.Add refuses a title that already exists, so Dialogue.Exists is checked first. For a later save load in the same session, run the same method from Events.Loaded as well, which is safe because Dialogue.Exists is checked before anything is added. The choice “Do you have any rope?” and the “Membership” menu both hang under “Back again?”. TextCallback on “Check the cellar.” shows the amount owed each time that line shows, and it only reads a variable. The F6 key starts the conversation with the NPC through NpcActions.StartDialogue, which takes the NPC’s id and the conversation title.
To add a choice to a game conversation instead, pass that conversation’s title and a line of its text to Dialogue.AddResponse or Dialogue.AddBranch in the same way. The title and the text must match what the game has.
Responses to a game conversation
Dialogue.AddResponse adds one player choice to a conversation the game already has, for example a second reply to an NPC line. Nothing is copied: the choice is written into the game’s own conversation for this session, and Dialogue.RemoveResponse or Dialogue.RemoveAll takes it out again. Experimental.
public static class Dialogue
{
public static string AddResponse(string modId, string conversationTitle, string anchorText, string playerText, string npcReply, string luaCondition, Action onPicked, int insertAt = -1, int priority = -1);
public static string AddResponse(string modId, string conversationTitle, int anchorId, string playerText, string npcReply, string luaCondition, Action onPicked, int insertAt = -1, int priority = -1);
public static string AddResponse(string modId, string conversationTitle, string anchorText, string playerText, string npcReply, string luaCondition, Action onPicked);
public static string AddResponse(string modId, string conversationTitle, int anchorId, string playerText, string npcReply, string luaCondition, Action onPicked);
public static bool RemoveResponse(string modId, string key);
}
- Anchor. The anchor is an NPC line of the conversation.
anchorTextis matched against the line’s authored Dialogue Text, not the translated text the player sees: an exact match is tried first, then a match that ignores surrounding spaces and letter case. Zero matches, or more than one, is refused, so use theanchorIdoverload when the text is not unique. A player line cannot be the anchor. An anchor whose links lead only to NPC lines is refused, because the game then shows that NPC line and never the choice. An anchor with no links is accepted. If the anchor links to both NPC and player lines, the game plays an NPC line whose condition is true instead of showing the choices, as it does for its own lines, so the new choice only shows when none of those NPC lines can play. - Shape. The player line is offered with the anchor’s other choices, at the same priority as they have unless
prioritysets another. IfnpcReplyis not null, the NPC answers the choice withnpcReply, and the conversation ends afternpcReply. If it is null, the conversation ends after the player line. - Position.
insertAtis the index in the anchor’s outgoing links where the choice is inserted, counting from 0, so0puts it first. The default-1appends, which places the choice after links the game appends itself, such as the leave option. An index past the end appends too. The position is kept when the framework writes the choice again after a scene load. - Priority.
priorityis the game’s link priority:0Low,1BelowNormal,2Normal,3AboveNormal,4High. The default-1copies the priority of the anchor’s own player links, andNormalwhen it has none. The game considers links of one priority tier at a time, from High down, so a higher priority offers the choice before the anchor’s own choices and a lower one after them. A value outside -1 to 4 throwsArgumentException. - Condition.
luaConditionis Lua, up to 500 characters, or null. The choice is offered only while it is true, as forConditionabove. - Text. A reply’s text can change from one visit to the next through Dialogue System variable markup: put
[var=Name]in the text, and setNamewithDialogue.SetVariable. - onPicked. Runs when the player picks the choice. It also runs when the choice is the only one and the game picks it for the player. An exception is logged once and does not reach the game.
- Key.
AddResponsereturns the key,modIdplus a dot and eight hex digits. The key comes from the conversation, the anchor and the texts, so the same arguments give the same key. CallingAddResponseagain with the same arguments returns that key and replacesonPicked. - Re-apply. The game can make new copies of its conversations on a scene load, so the framework adds the responses back on every scene load and after each load of a save. Your mod does not re-add them. When the conversation or the anchor is not in the database at that moment, the framework keeps trying once per frame for a bounded number of frames and logs one failure per key when it gives up, so a response whose conversation loads late is applied when it appears. A later scene load or save load starts the attempts again.
- Refusals.
AddResponsereturnsnullwhen the dialogue system is not installed or not ready (no game scene yet), when a save or load is running, when a text is empty or too long, when the conversation is not in the database, when the anchor is missing, ambiguous, not an NPC line, or links only to NPC lines, or when the database has no player actor. An emptyconversationTitleorplayerTextthrowsArgumentException, and so do aninsertAtbelow -1 and apriorityoutside -1 to 4. The original seven-argument form withoutinsertAtandpriorityis kept as a thin wrapper, so a mod compiled against 0.11.0-beta still binds it; it is the same as passing -1 for both. - Remove.
RemoveResponsereturnsfalsefor another mod’s key, an unknown key, or while a save or load is running.Dialogue.RemoveAll(modId)also removes the mod’s interceptors and Lua functions, whatever the save state. Its responses are kept while a save or load is running, asRemoveResponserefuses then. - Saves. The framework never saves responses. A save made while a response is in place loads without the mod: the read-state rows of the response lines are ignored.
string key = Dialogue.AddResponse("example.mymod", "Shop.Greeting", "Need anything today?", "Do you have any rope?", "Second shelf, by the door.", "not mymod_asked", () => Dialogue.SetVariable("mymod_asked", true));
if (key == null) return;
Branches under a game NPC line
Dialogue.AddBranch links a conversation of your own lines into a game conversation, for a menu tree. The branch is an authored conversation, so the rules above for ids, validation and re-adding apply to it. Experimental.
public static class Dialogue
{
public static string AddBranch(string modId, string conversationTitle, string anchorText, ConversationDefinition branch, int insertAt = -1, int priority = -1);
public static string AddBranch(string modId, string conversationTitle, int anchorId, ConversationDefinition branch, int insertAt = -1, int priority = -1);
public static string AddBranch(string modId, string conversationTitle, string anchorText, ConversationDefinition branch);
public static string AddBranch(string modId, string conversationTitle, int anchorId, ConversationDefinition branch);
public static string AddBranch(string modId, string conversationTitle, string anchorText, ConversationDefinition branch, Action onPicked);
public static string AddBranch(string modId, string conversationTitle, int anchorId, ConversationDefinition branch, Action onPicked);
public static string AddBranch(string modId, string conversationTitle, string anchorText, ConversationDefinition branch, int insertAt, int priority, Action onPicked);
public static string AddBranch(string modId, string conversationTitle, int anchorId, ConversationDefinition branch, int insertAt, int priority, Action onPicked);
public static string AddQuestTopic(string modId, string conversationTitle, string playerLine, string[] npcLines, string showWhen, Action onDone);
public static bool RemoveBranch(string modId, string key);
}
- Anchor. The anchor is an NPC line of the game conversation, chosen as for
AddResponse, and the same refusals apply. For a line with aTextCallback, the authored text is the callback’s markup, so use theanchorIdoverload for that line. - Link. The branch starts at its first line, the first entry of
Lines. The anchor gets one link to that line, at the priority of its other player choices unlessprioritysets another, and at the indexinsertAtin the anchor’s links (-1appends, as it does forAddResponse). The first line may be a player line, which the player picks, or a character line, which plays straight after the anchor. A character first line gets one priority tier below the anchor’s player choices by default, because the game takes a character line before the player’s choices when both are at the same tier (see the responses page): the branch then plays only when no choice above it can. Passpriorityto place it yourself, and give the line aConditionto decide when it plays. When the anchor’s player choices are already at the lowest tier, there is no tier below them and the branch shares theirs, so it is shown instead of the menu; that is logged once. - onPicked.
onPickedruns when the player picks the branch, that is, when its first line is reached. It also runs when the branch is the only choice and the game picks it for the player. An exception is logged once and does not reach the game. Adding the same branch again is refused as before, so a later call with anotheronPickeddoes not replace the first. - Not loaded yet. When the anchor conversation or the anchor is not in the database at the time of the call,
AddBranchstill gives back the key and links the branch as soon as both appear. The link is tried again on each scene load and each load of a save, and on the same bounded per-frame retries as a re-apply. A branch that can never be linked, such as one whose anchor text is matched by more than one line, is refused once and then left alone. - Speaker. The branch’s NPC lines are spoken by the anchor’s NPC, so its name and portrait show.
branch.Speakermay be null or the name of that same actor. Any other name is refused. - Key.
AddBranchreturns the key,modIdplus a dot and eight hex digits. It comes from the conversation title, the anchor andbranch.Key. CallingAddBranchagain with the same arguments is refused, so callRemoveBranchfirst. The key comes from the arguments of the call, not from the anchor entry that was found, so it is the same whether the anchor was loaded yet or not; a second call while the first branch is still waiting is refused as already added. The branch’s conversation title is the mod id, a dot andbranch.Key, so a key already in the database is refused asAlreadyExists, also when it is used on another anchor. - Re-apply. The framework writes the anchor link again after each scene load and each load of a save, as it does for responses, and retries it the same bounded way when the conversation, the anchor or the branch conversation is not in the database yet. Your mod does not re-add it.
- Remove.
RemoveBranchremoves the link and the branch’s conversation. It returnsfalsefor another mod’s key, an unknown key, a save or load running, the dialogue database not ready, or while any conversation is running, because the player can reach a branch through another conversation’s link.Dialogue.RemoveAll(modId)removes the mod’s branches too, and keeps any it cannot remove now, while a save, a load or a conversation is running.DialogueAuthoring.Clear(modId)does the same. A branch conversation that is still in place cannot be removed withDialogueAuthoring.Remove. - Refusals.
AddBranchreturnsnulland logs the reason once when the dialogue system is not ready, a save or load is running, the branch is not valid, the branch key isnarration,anchorTextis empty or only spaces, the anchor is matched by more than one line, the anchor is not an NPC line, its links lead only to NPC lines, or the branch speaker is not the anchor’s NPC. A conversation or an anchor that is simply not in the database yet is not refused: the branch waits for it, as described above. An emptyconversationTitlethrowsArgumentException. A nullbranchthrowsArgumentNullException, an invalidmodIdthrowsArgumentException, and so do aninsertAtbelow -1 and apriorityoutside -1 to 4. The original four-argument form withoutinsertAtandpriorityis kept as a thin wrapper as well, and so are the two forms withoutonPicked. - Saves. Like responses, the framework never saves branches. A save made while a branch is in place loads without the mod.
ConversationDefinition membership = new ConversationDefinition { Key = "membership" };
membership.Lines.Add(new ConversationLine { Key = "ask", Player = true, Text = "Membership", Next = new[] { "status" } });
membership.Lines.Add(new ConversationLine
{
Key = "status",
Text = "Your card is in order.",
TextCallback = () => "You have " + MembershipDaysLeft() + " days left.",
Next = new[] { "renew", "back" }
});
membership.Lines.Add(new ConversationLine { Key = "renew", Player = true, Text = "Renew for 30 days.", Script = "Variable[\"mymod_renew\"] = true" });
membership.Lines.Add(new ConversationLine { Key = "back", Player = true, Text = "Goodbye." });
string key = Dialogue.AddBranch("example.mymod", "Shop.Greeting", "What can I do for you?", membership);
The tree is: the anchor “What can I do for you?”, then the player choice “Membership”, then the NPC reply with the days left, then the two choices “Renew for 30 days.” and “Goodbye.”.
A line without Next ends the conversation. A branch cannot return to the game conversation, so “Goodbye.” ends the talk and the game’s menu is not shown again. Put any choice that should go back to the game’s menu in the game conversation itself, not in a branch.
Quest topics
Dialogue.AddQuestTopic(modId, conversationTitle, playerLine, npcLines, showWhen, onDone) is a short way to add one topic to a conversation the game already has. It builds the lines for you and calls Dialogue.AddBranch with them, under the first character line of the conversation. Experimental, and every rule above for branches applies.
public static class Dialogue
{
public static string AddQuestTopic(string modId, string conversationTitle, string playerLine, string[] npcLines, string showWhen, Action onDone);
}
- Shape.
playerLinebecomes a player choice under the conversation’s first character line, shown only whileshowWhenis true. That line is the first entry of the conversation that is not a group entry, has text, is spoken by a character, and can reach a player choice; the root entry and character lines whose links lead only to character lines are skipped.npcLinesare spoken by that character, one after another, in the order you give them, and the conversation ends after the last one.onDoneruns when the player picks the topic. - Key. The branch key comes from the conversation, the anchor line and
playerLine, and starts withtopic, so two topics in one conversation never share a key. The same arguments give the same key. - Refusals. It returns
nulland logs the reason once when the dialogue system is not ready, the conversation is not in the database, the conversation has no character line that can take a topic, or the branch is refused. UnlikeAddBranch, it needs the conversation loaded at the time of the call, because it looks for that line at once, and it refuses rather than waiting for the conversation to appear. An emptyconversationTitle, an emptyplayerLine, and a null or emptynpcLinesreturnnulland log the reason once instead of throwingArgumentException.
string key = Dialogue.AddQuestTopic("example.mymod", "Shop.Greeting", "Any news about the cellar?", new[] { "Still locked.", "Ask me tomorrow." }, "Quest[\"mymod_cellar\"] ~= \"Finished\"", () => Dialogue.SetVariable("mymod_asked", true));
Text callbacks
ConversationLine.TextCallback sets a line’s text from a function. The game calls it when the line shows, so the text can change from one visit to the next. It works for a conversation from DialogueAuthoring.Add and for a branch.
- The callback returns the text. When it returns null, the line shows
Textinstead. When it throws, the line shows nothing and the exception is logged once. - A returned text over 1000 characters, or with a control character, is replaced by
Textand logged once. - When
TextCallbackis set,Textmay be null. Without a callback,Textis required as before. - The callback can run several times for one showing of a line. While the game works out the links, it parses each NPC reply it considers, and it parses a player choice once for the menu and again for the subtitle. The callback must have no side effects.
- The callback runs on the game’s main thread while the conversation is open. Keep it short. Do not start a conversation or write to the save from it.
- The returned text is parsed as Dialogue System markup, as authored text is:
[var=Name], emphasis tags such as[em1], and|as a line break (on by default in the game’s subtitle settings) all work. Removedrops the callbacks of that conversation, andRemoveBranchdrops those of its branch.DialogueAuthoring.Cleardrops those of each conversation it removes, andDialogue.RemoveAlldrops those of the branches it removes.Dialogue.RemoveAlldoes not remove conversations made withDialogueAuthoring.Add, so useClearfor those.
Narration
Dialogue.Narrate(modId, text) (experimental) shows text in the game’s dialogue box on the game’s black screen. It is built on this API, with one conversation per mod: the key is narration, so the title is modId.narration. Each call replaces that conversation’s text, then starts it through the game’s BlackCanvas.StartDialogue.
public static class Dialogue
{
public static bool Narrate(string modId, string text);
}
- It returns
truewhen a conversation is running after the call. That can be a different conversation when a handler fromDialogue.Interceptredirects the start. - It returns
falsewhen the text is null, empty, only spaces, over 1000 characters, or has a control character other than a line break (\nor\r). It also returnsfalsewhenDialogue.IsAvailableis false, the dialogue system is not ready, a save or load is running, a conversation is already running,BlackCanvas.instanceis null, or the player is waiting or asleep. It returnsfalsetoo whenDialogueAuthoring.Addrefuses the conversation, for example when the database has no player actor. - A call from a wake-up callback is refused while the sleep event’s text (
WaitingController.eventDialogue) is still waiting to show, for the same reason: starting a conversation wakes the player and cuts that text off. - The screen starts to fade to black at once, before the conversation text shows.
- When the conversation ends, the framework fades the screen back out. It uses the game’s end-of-talk callback on the black screen’s talk component. The framework keeps any callback already set there and runs it after the fade. If the conversation does not start, the framework fades the screen back at once and
Narratereturnsfalse. If the start throws after the conversation has begun,Narratereturnstrueand the fade back happens when that conversation ends. - An invalid
modIdthrowsArgumentException, as for the otherDialoguecalls. - The key
narrationbelongs toNarrate. Each call removes the mod’s own conversation with that key first.AddandAddBranchrefuse the keynarration(ReservedKey), and the refusal is logged once.
Reference
The types and calls the examples above use. The rules for each one are in the sections above.
ConversationDefinition a conversation your mod writes
Pass one of these to DialogueAuthoring.Add. Key is the part of the title your mod chooses, and Lines holds the lines.
public sealed class ConversationDefinition
{
public string Key { get; set; }
public string Speaker { get; set; }
public List<ConversationLine> Lines { get; set; }
}
ConversationLine one line a character or the player says
A line is a character line unless Player is true, which makes it a choice the player picks. Next lists the keys of the lines that follow it, and a line without Next ends the conversation.
public sealed class ConversationLine
{
public string Key { get; set; }
public bool Player { get; set; }
public string Text { get; set; }
public string Condition { get; set; }
public string Script { get; set; }
public string[] Next { get; set; }
public Func<string> TextCallback { get; set; }
}
ConversationRefusal why a conversation was not added
Add sets this through its out refusal parameter. None means nothing was refused.
public enum ConversationRefusal
{
None,
NotReady,
SaveBusy,
BadInput,
AlreadyExists,
UnknownSpeaker,
NoPlayerActor,
Failed,
ReservedKey
}
DialogueAuthoring adds, removes and names your conversations
TitleOf gives the title a conversation of yours gets, so you can check it with Dialogue.Exists. Add returns that title, or null with the reason in refusal.
public static class DialogueAuthoring
{
public static string TitleOf(string modId, string key);
public static string Add(string modId, ConversationDefinition definition, out ConversationRefusal refusal);
public static bool Remove(string modId, string key);
public static int Clear(string modId);
}
Dialogue starts, checks and follows conversations
Start begins a conversation with two Transform sides from Unity. Exists tells you whether a title is in the game’s database, and IsActive and CurrentTitle tell you what is running. NpcActions.StartDialogue starts one with an NPC by its id, as the examples on the common tasks page do.
public static class Dialogue
{
public static bool Start(string title, Transform actor, Transform conversant);
public static void Stop();
public static bool IsActive { get; }
public static string CurrentTitle { get; }
public static bool Exists(string title);
public static event Action<DialogueArgs> Started;
public static event Action<DialogueArgs> Ended;
}
Self-check
Set Debug.DialogueAuthoringSelfCheck in the framework’s config to log a check of the definition rules, the line links and the speakers at start. Set Debug.DialogueResponsesSelfCheck to log a check of the anchor matching and the id allocation for responses.