From 04a115a6261124e172f22e4e2c8b4d9fbdbdfe86 Mon Sep 17 00:00:00 2001 From: chrisgregan <chris@snozbot.com> Date: Mon, 16 Mar 2015 11:13:04 +0000 Subject: [PATCH] Export and import dialog text for editing in a text editor #86 Added support for Fountain export / import. http://fountain.io/ --- .../FungusScript/Editor/CommandEditor.cs | 4 + .../FungusScript/Editor/CommandListAdaptor.cs | 2 + .../FungusScript/Editor/FungusScriptEditor.cs | 17 +- .../FungusScript/Editor/FungusScriptWindow.cs | 1 + .../FungusScript/Editor/SequenceEditor.cs | 8 +- Assets/Fungus/FungusScript/Scripts/Command.cs | 3 + .../FungusScript/Scripts/FountainExporter.cs | 187 ++++++++++++++++++ .../Scripts/FountainExporter.cs.meta | 12 ++ .../FungusScript/Scripts/FungusScript.cs | 33 ++++ .../FungusScript/Scripts/StringsParser.cs | 125 ++++++------ 10 files changed, 319 insertions(+), 73 deletions(-) create mode 100644 Assets/Fungus/FungusScript/Scripts/FountainExporter.cs create mode 100644 Assets/Fungus/FungusScript/Scripts/FountainExporter.cs.meta diff --git a/Assets/Fungus/FungusScript/Editor/CommandEditor.cs b/Assets/Fungus/FungusScript/Editor/CommandEditor.cs index 8887165c..f2e7e2db 100644 --- a/Assets/Fungus/FungusScript/Editor/CommandEditor.cs +++ b/Assets/Fungus/FungusScript/Editor/CommandEditor.cs @@ -71,6 +71,10 @@ namespace Fungus GUILayout.FlexibleSpace(); + GUILayout.Label(new GUIContent("(" + t.commandId + ")")); + + GUILayout.Space(10); + GUI.backgroundColor = Color.white; bool enabled = t.enabled; enabled = GUILayout.Toggle(enabled, new GUIContent()); diff --git a/Assets/Fungus/FungusScript/Editor/CommandListAdaptor.cs b/Assets/Fungus/FungusScript/Editor/CommandListAdaptor.cs index 99c8f0bc..9f94b94a 100644 --- a/Assets/Fungus/FungusScript/Editor/CommandListAdaptor.cs +++ b/Assets/Fungus/FungusScript/Editor/CommandListAdaptor.cs @@ -94,6 +94,7 @@ namespace Fungus } Command newCommand = Undo.AddComponent<Comment>(sequence.gameObject) as Command; + newCommand.commandId = fungusScript.NextCommandId(); fungusScript.ClearSelectedCommands(); fungusScript.AddSelectedCommand(newCommand); @@ -109,6 +110,7 @@ namespace Fungus System.Type type = command.GetType(); Command newCommand = Undo.AddComponent(parentSequence.gameObject, type) as Command; + newCommand.commandId = newCommand.GetFungusScript().NextCommandId(); System.Reflection.FieldInfo[] fields = type.GetFields(); foreach (System.Reflection.FieldInfo field in fields) { diff --git a/Assets/Fungus/FungusScript/Editor/FungusScriptEditor.cs b/Assets/Fungus/FungusScript/Editor/FungusScriptEditor.cs index 7f77c4c5..3019dfab 100644 --- a/Assets/Fungus/FungusScript/Editor/FungusScriptEditor.cs +++ b/Assets/Fungus/FungusScript/Editor/FungusScriptEditor.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using Rotorz.ReorderableList; using System.Linq; using System.Reflection; +using System.IO; namespace Fungus { @@ -21,6 +22,7 @@ namespace Fungus protected SerializedProperty colorCommandsProp; protected SerializedProperty hideComponentsProp; protected SerializedProperty runSlowDurationProp; + protected SerializedProperty saveSelectionProp; protected SerializedProperty variablesProp; protected virtual void OnEnable() @@ -29,6 +31,7 @@ namespace Fungus colorCommandsProp = serializedObject.FindProperty("colorCommands"); hideComponentsProp = serializedObject.FindProperty("hideComponents"); runSlowDurationProp = serializedObject.FindProperty("runSlowDuration"); + saveSelectionProp = serializedObject.FindProperty("saveSelection"); variablesProp = serializedObject.FindProperty("variables"); } @@ -44,6 +47,7 @@ namespace Fungus EditorGUILayout.PropertyField(colorCommandsProp); EditorGUILayout.PropertyField(hideComponentsProp); EditorGUILayout.PropertyField(runSlowDurationProp); + EditorGUILayout.PropertyField(saveSelectionProp); GUILayout.BeginHorizontal(); GUILayout.FlexibleSpace(); @@ -51,6 +55,15 @@ namespace Fungus { EditorWindow.GetWindow(typeof(FungusScriptWindow), false, "Fungus Script"); } + if (GUILayout.Button(new GUIContent("Export Text", "Export all story text in .fountain format."))) + { + FountainExporter.ExportStrings(fungusScript); + } + if (GUILayout.Button(new GUIContent("Import Text", "Import story text from a file in .fountain format."))) + { + FountainExporter.ImportStrings(fungusScript); + } + GUILayout.FlexibleSpace(); GUILayout.EndHorizontal(); @@ -213,7 +226,7 @@ namespace Fungus derivedType.IsAssignableFrom(t) ).ToList(); - } + } } - + } \ No newline at end of file diff --git a/Assets/Fungus/FungusScript/Editor/FungusScriptWindow.cs b/Assets/Fungus/FungusScript/Editor/FungusScriptWindow.cs index 1244496a..ae6a954f 100755 --- a/Assets/Fungus/FungusScript/Editor/FungusScriptWindow.cs +++ b/Assets/Fungus/FungusScript/Editor/FungusScriptWindow.cs @@ -689,6 +689,7 @@ namespace Fungus { field.SetValue(newCommand, field.GetValue(command)); } + newCommand.commandId = fungusScript.NextCommandId(); newSequence.commandList.Add(newCommand); } diff --git a/Assets/Fungus/FungusScript/Editor/SequenceEditor.cs b/Assets/Fungus/FungusScript/Editor/SequenceEditor.cs index 7a0b194c..4ac5c4b2 100644 --- a/Assets/Fungus/FungusScript/Editor/SequenceEditor.cs +++ b/Assets/Fungus/FungusScript/Editor/SequenceEditor.cs @@ -528,12 +528,15 @@ namespace Fungus { return; } - - sequence.GetFungusScript().ClearSelectedCommands(); + + FungusScript fungusScript = sequence.GetFungusScript(); + + fungusScript.ClearSelectedCommands(); Command newCommand = Undo.AddComponent(sequence.gameObject, commandOperation.commandType) as Command; sequence.GetFungusScript().AddSelectedCommand(newCommand); newCommand.parentSequence = sequence; + newCommand.commandId = fungusScript.NextCommandId(); // Let command know it has just been added to the sequence newCommand.OnCommandAdded(sequence); @@ -741,6 +744,7 @@ namespace Fungus Command pastedCommand = commands.Last<Command>(); if (pastedCommand != null) { + pastedCommand.commandId = fungusScript.NextCommandId(); fungusScript.selectedSequence.commandList.Insert(pasteIndex++, pastedCommand); } } diff --git a/Assets/Fungus/FungusScript/Scripts/Command.cs b/Assets/Fungus/FungusScript/Scripts/Command.cs index 256c5b94..49ad8971 100644 --- a/Assets/Fungus/FungusScript/Scripts/Command.cs +++ b/Assets/Fungus/FungusScript/Scripts/Command.cs @@ -31,6 +31,9 @@ namespace Fungus public class Command : MonoBehaviour { + [HideInInspector] + public int commandId = -1; // Invalid command id + [HideInInspector] public string errorMessage = ""; diff --git a/Assets/Fungus/FungusScript/Scripts/FountainExporter.cs b/Assets/Fungus/FungusScript/Scripts/FountainExporter.cs new file mode 100644 index 00000000..abcb6f9b --- /dev/null +++ b/Assets/Fungus/FungusScript/Scripts/FountainExporter.cs @@ -0,0 +1,187 @@ +using UnityEditor; +using UnityEngine; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.IO; + +namespace Fungus +{ + + /** + * Import and export a Fungus story in the .fountain screenplay format. + * The exported file contains special tags in note blocks which map the + * story text to the corresponding commands. + */ + public class FountainExporter + { + + public static void ExportStrings(FungusScript fungusScript) + { + if (fungusScript == null) + { + return; + } + + string path = EditorUtility.SaveFilePanel("Export strings", "", + fungusScript.name + ".txt", ""); + + if(path.Length == 0) + { + return; + } + + // Write out character names + + string exportText = "Title: " + fungusScript.name + "\n"; + exportText += "Draft date: " + System.DateTime.Today.ToString("d") + "\n"; + exportText += "\n"; + + // In every sequence, write out Say & Menu text in order + Sequence[] sequences = fungusScript.GetComponentsInChildren<Sequence>(); + foreach (Sequence s in sequences) + { + // Check for any Say, Menu or Comment commands + bool hasText = false; + foreach (Command c in s.commandList) + { + System.Type t = c.GetType(); + if (t == typeof(Say) || + t == typeof(Menu) || + t == typeof(Comment)) + { + hasText = true; + } + } + if (!hasText) + { + continue; + } + + exportText += "." + s.sequenceName.ToUpper() + "\n\n"; + + foreach (Command c in s.commandList) + { + if (c.GetType() == typeof(Say)) + { + string idText = ""; + Say say = c as Say; + + if (say.character == null) + { + exportText += "NO CHARACTER\n"; + } + else + { + exportText += say.character.nameText.ToUpper() + "\n"; + } + + idText += "[[Say," + c.commandId + "]]\n"; + + exportText += idText; + + // Fountain requires blank dialogue lines to contain 2 spaces or else + // they will be interpreted as ACTION text. + string trimmedText = say.storyText.Trim(); + string[] lines = trimmedText.Split(new [] { '\r', '\n' }); + foreach (string line in lines) + { + string trimmed = line.Trim(); + if (line.Length == 0) + { + exportText += " \n"; + } + else + { + exportText += trimmed + "\n"; + } + } + + exportText += "\n"; + } + else if (c.GetType() == typeof(Menu)) + { + exportText += "MENU\n"; + + string idText = ""; + Menu menu = c as Menu; + idText += "[[Menu," + c.commandId + "]]\n"; + + exportText += idText + menu.text.Trim() + "\n\n"; + } + else if (c.GetType() == typeof(Comment)) + { + string idText = ""; + Comment comment = c as Comment; + idText += "[[Comment," + c.commandId + "]]\n"; + + exportText += idText + comment.commentText.Trim() + "\n\n"; + } + } + } + + File.WriteAllText(path, exportText); + } + + public static void ImportStrings(FungusScript fungusScript) + { + string path = EditorUtility.OpenFilePanel("Import strings", "", ""); + + if(path.Length == 0) + { + return; + } + + string stringsFile = File.ReadAllText(path); + + StringsParser parser = new StringsParser(); + List<StringsParser.StringItem> items = parser.ProcessText(stringsFile); + + // Build dict of commands + Dictionary<int, Command> commandDict = new Dictionary<int, Command>(); + foreach (Command c in fungusScript.gameObject.GetComponentsInChildren<Command>()) + { + commandDict.Add (c.commandId, c); + } + + foreach (StringsParser.StringItem item in items) + { + if (item.parameters.Length != 2) + { + continue; + } + + string stringType = item.parameters[0]; + if (stringType == "Say") + { + int commandId = int.Parse(item.parameters[1]); + Say sayCommand = commandDict[commandId] as Say; + if (sayCommand != null) + { + sayCommand.storyText = item.bodyText; + } + } + else if (stringType == "Menu") + { + int commandId = int.Parse(item.parameters[1]); + Menu menuCommand = commandDict[commandId] as Menu; + if (menuCommand != null) + { + menuCommand.text = item.bodyText; + } + } + else if (stringType == "Comment") + { + int commandId = int.Parse(item.parameters[1]); + Comment commentCommand = commandDict[commandId] as Comment; + if (commentCommand != null) + { + commentCommand.commentText = item.bodyText; + } + } + } + } + } + +} diff --git a/Assets/Fungus/FungusScript/Scripts/FountainExporter.cs.meta b/Assets/Fungus/FungusScript/Scripts/FountainExporter.cs.meta new file mode 100644 index 00000000..e54b9dc3 --- /dev/null +++ b/Assets/Fungus/FungusScript/Scripts/FountainExporter.cs.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: c91ad6ef6a0734046bd93dde4b0e59d1 +timeCreated: 1426502899 +licenseType: Free +MonoImporter: + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Fungus/FungusScript/Scripts/FungusScript.cs b/Assets/Fungus/FungusScript/Scripts/FungusScript.cs index 19908782..448cd0d6 100644 --- a/Assets/Fungus/FungusScript/Scripts/FungusScript.cs +++ b/Assets/Fungus/FungusScript/Scripts/FungusScript.cs @@ -12,6 +12,7 @@ namespace Fungus * Visual scripting controller for the Fungus Script programming language. * FungusScript objects may be edited visually using the Fungus Script editor window. */ + [ExecuteInEditMode] [AddComponentMenu("Fungus/Fungus Script")] public class FungusScript : MonoBehaviour { @@ -91,6 +92,38 @@ namespace Fungus [Tooltip("Saves the selected sequence and commands when saving the scene.")] public bool saveSelection = true; + /** + * Unique id to assign to the next created command. + * Increases monotonically every time a new command is added to the sequence. + */ + [SerializeField] + protected int nextCommandId = 0; + + /** + * Returns the next command id to assign to a new command. + * Command ids increase monotically so they are guaranteed to + * be unique within a FungusScript. + */ + public int NextCommandId() + { + return nextCommandId++; + } + + public virtual void OnEnable() + { + // Assign a command id to any command that doesn't have one yet. + // This should only happen after loading a legacy FungusScript + Command[] commands = GetComponentsInChildren<Command>(); + foreach (Command command in commands) + { + if (command.commandId == -1) + { + command.commandId = NextCommandId(); + Debug.Log("Assigning command id: " + command.commandId); + } + } + } + protected virtual Sequence CreateSequenceComponent(GameObject parent) { Sequence s = parent.AddComponent<Sequence>(); diff --git a/Assets/Fungus/FungusScript/Scripts/StringsParser.cs b/Assets/Fungus/FungusScript/Scripts/StringsParser.cs index e88269b3..9dce7ea9 100644 --- a/Assets/Fungus/FungusScript/Scripts/StringsParser.cs +++ b/Assets/Fungus/FungusScript/Scripts/StringsParser.cs @@ -7,103 +7,90 @@ using Fungus; namespace Fungus { /** - * Parses a text file using a simple format and adds string values to the global string table. - * The format is: - * $FirstString - * The first string text goes here - * $SecondString - * The second string text goes here - * # This is a comment line and will be ignored by the parser + * Parses an exported strings file using the Fountain file format for screenplays + * See http://fountain.io for details. + * We only support a small subset of Fountain markup, and use note tags to embed meta data to + * bind dialogue text to the corresponding Say / Menu commands. */ - public class StringsParser : MonoBehaviour + public class StringsParser { - public TextAsset stringsFile; - - private enum ParseMode + public class StringItem { - Idle, - Text, - }; - - public virtual void Start() - { - ProcessText(stringsFile.text); + public string[] parameters; + public string bodyText; } - - protected virtual void ProcessText(string text) + + public virtual List<StringItem> ProcessText(string text) { + List<StringItem> items = new List<StringItem>(); + // Split text into lines. Add a newline at end to ensure last command is always parsed string[] lines = Regex.Split(text + "\n", "(?<=\n)"); - - string blockBuffer = ""; - ParseMode mode = ParseMode.Idle; - - string blockTag = ""; - for (int i = 0; i < lines.Length; ++i) + int i = 0; + while (i < lines.Length) { - string line = lines[i]; - - bool newBlock = line.StartsWith("$"); + string line = lines[i].Trim(); - if (mode == ParseMode.Idle && !newBlock) + if (!(line.StartsWith("[[") && line.EndsWith("]]"))) { - // Ignore any text not preceded by a label tag + i++; continue; } - string newBlockTag = ""; - if (newBlock) - { - newBlockTag = line.Replace ("\n", ""); - } - - bool endOfFile = (i == lines.Length-1); + string blockTag = line.Substring(2, line.Length - 4); - bool storeBlock = false; - - if (newBlock) + // Find next empty line, #, [[ or eof + int start = i + 1; + int end = lines.Length - 1; + for (int j = start; j <= end; ++j) { - storeBlock = true; - } - else if (mode == ParseMode.Text && endOfFile) - { - storeBlock = true; - if (!line.StartsWith("#")) + string line2 = lines[j].Trim(); + + if (line2.Length == 0 || + line2.StartsWith("#") || + line2.StartsWith("[[")) { - blockBuffer += line; + end = j; + break; } } - else + + if (end > start) { - if (!line.StartsWith("#")) + string blockBuffer = ""; + for (int j = start; j <= end; ++j) { - blockBuffer += line; + blockBuffer += lines[j].Trim() + "\n"; } - } - if (storeBlock) - { - if (blockTag.Length > 0 && blockBuffer.Length > 0) + blockBuffer = blockBuffer.Trim(); + + StringItem item = CreateItem(blockTag, blockBuffer); + if (item != null) { - // Trim off last newline - blockBuffer = blockBuffer.TrimEnd( '\r', '\n', ' ', '\t'); + items.Add(item); + } + } - // TODO: Store in a string table class - // GlobalVariables.SetString(blockTag, blockBuffer); - } + i = end + 1; + } - // Prepare to parse next block - mode = ParseMode.Idle; - if (newBlock) - { - mode = ParseMode.Text; - blockTag = newBlockTag; - } + return items; + } - blockBuffer = ""; - } + protected StringItem CreateItem(string commandInfo, string bodyText) + { + string[] parameters = commandInfo.Split(new char[] { ',' }); + if (parameters.Length > 0) + { + StringItem item = new StringItem(); + item.parameters = parameters; + item.bodyText = bodyText; + return item; } + + return null; } } }