IEnumerator RunDialogue(string startNode = "Start") { // Get lines, options and commands from the Dialogue object, one at a time. foreach (Yarn.Dialogue.RunnerResult step in dialogue.Run(startNode)) { CurrentNode = dialogue.currentNode; if (step is Yarn.Dialogue.LineResult) { // Wait for line to finish displaying var lineResult = step as Yarn.Dialogue.LineResult; yield return(this.StartCoroutine(RunLine(lineResult.line))); } else if (step is Yarn.Dialogue.OptionSetResult) { // Wait for user to finish picking an option var optionSetResult = step as Yarn.Dialogue.OptionSetResult; RunOptions(optionSetResult.options, optionSetResult.setSelectedOptionDelegate); yield return(new WaitWhile(() => inputOption < 0)); } else if (step is Yarn.Dialogue.CommandResult) { // Wait for command to finish running var commandResult = step as Yarn.Dialogue.CommandResult; yield return(this.StartCoroutine(RunCommand(commandResult.command))); } } MerinoDebug.Log(LoggingLevel.Info, "Reached the end of the dialogue."); CurrentNode = null; // No more results! The dialogue is done. yield return(new WaitUntil(() => MerinoPrefs.stopOnDialogueEnd)); StopPlaytest_Internal(); }
/// <summary> /// Guesses the line number of a given line. Used for the View Node Source button. /// </summary> int GuessLineNumber(int nodeID, string lineText) { var node = MerinoData.GetNode(nodeID); // this is a really bad way of doing it, but Yarn Spinner's DialogueRunner doesn't offer any access to line numbers. if (node != null && node.nodeBody.Contains(lineText)) { var lines = node.nodeBody.Split('\n'); for (int i = 0; i < lines.Length; i++) { if (lines[i].Contains(lineText)) { return(i + 1); } } return(-1); } if (node == null) { MerinoDebug.LogFormat(LoggingLevel.Warning, "Couldn't find node ID {0}. It might've been deleted or the Yarn file might be corrupted.", nodeID); } return(-1); }
public static void CheckFileList(IList <string> paths, List <string> allowedExtensions) { if (paths.Count == 0) { MerinoDebug.Log(LoggingLevel.Warning, "No files provided."); return; } var invalid = new List <string>(); foreach (var path in paths) { // Does this file exist? var exists = System.IO.File.Exists(path); // Does this file have the right extension? var hasAllowedExtension = allowedExtensions.FindIndex(item => path.EndsWith(item)) != -1; if (!exists || !hasAllowedExtension) { invalid.Add(string.Format("\"{0}\"", path)); } } if (invalid.Count != 0) { var message = string.Format("The file{0} {1} {2}.", invalid.Count == 1 ? "" : "s", string.Join(", ", invalid.ToArray()), invalid.Count == 1 ? "is not valid" : "are not valid" ); MerinoDebug.Log(LoggingLevel.Error, message); } }
public static TextAsset GetDefaultData() { var defaultData = Resources.Load<TextAsset>(MerinoPrefs.newFileTemplatePath); if (defaultData == null) { MerinoDebug.Log(LoggingLevel.Warning, "Merino couldn't find the new file template at Resources/" + MerinoPrefs.newFileTemplatePath + ". Double-check the file exists there, or you can override this path in EditorPrefs."); return null; } return defaultData; }
// strip forbidden characters from node titles public static string CleanNodeTitle(string newName) { // v0.6, added regex to disallow forbidden characters in node titles string newNameClean = Regex.Replace(newName, forbiddenCharactersInNodeTitles_regex, ""); if (newName.Length != newNameClean.Length) { // GUI.Label( GUILayoutUtility.GetLastRect(), new GUIContent("Yarn node titles cannot use <>[]{}|:#$ or whitespace", helpIcon), EditorStyles.helpBox ); MerinoDebug.Log(LoggingLevel.Verbose, "Merino stripped forbidden characters <>[]{}|:#$ (and whitespace) from your node title.\n" + newName + " ... " + newNameClean); return(newNameClean); } return(newName); }
public static int ConvertToYarn(ConvertFormatOptions options) { foreach (var file in options.files) { if (YarnSpinnerLoader.GetFormatFromFileName(file) == NodeFormat.Text) { MerinoDebug.LogFormat(LoggingLevel.Warning, "Not converting file {0}, because its name implies it's already in Yarn format", file); continue; } ConvertNodesInFile(options, file, "yarn.txt", ConvertNodesToYarnText); } return(0); }
public static int ConvertToJSON(ConvertFormatOptions options) { foreach (var file in options.files) { if (YarnSpinnerLoader.GetFormatFromFileName(file) == NodeFormat.JSON) { MerinoDebug.LogFormat(LoggingLevel.Warning, "Not converting file {0}, because its name implies it's already in JSON format", file); continue; } ConvertNodesInFile(options, file, "json", (IEnumerable <YarnSpinnerLoader.NodeInfo> nodes) => JsonConvert.SerializeObject(nodes, Formatting.Indented)); } return(0); }
public static TextAsset LoadYarnFileAtFullPath(string path, bool isRelativePath=false) { var newFile = AssetDatabase.LoadAssetAtPath<TextAsset>( isRelativePath ? path : "Assets" + path.Substring(Application.dataPath.Length) ); if (MerinoData.CurrentFiles.Contains(newFile) == false) { MerinoData.CurrentFiles.Add(newFile); } else { MerinoDebug.Log(LoggingLevel.Warning, "Merino: file at " + path + " is already loaded!"); } return newFile; }
/// Erase all variables and reset to default values public void ResetToDefaults() { Clear(); // For each default variable that's been defined, parse the string // that the user typed in in Unity and store the variable foreach (var variable in defaultVariables) { object value; switch (variable.type) { case Yarn.Value.Type.Number: float f = 0.0f; float.TryParse(variable.value, out f); value = f; break; case Yarn.Value.Type.String: value = variable.value; break; case Yarn.Value.Type.Bool: bool b = false; bool.TryParse(variable.value, out b); value = b; break; case Yarn.Value.Type.Variable: // We don't support assigning default variables from other variables // yet MerinoDebug.LogFormat(LoggingLevel.Error, "Can't set variable {0} to {1}: You can't set a default variable to be another variable, because it may not have been initialised yet.", variable.name, variable.value); continue; case Yarn.Value.Type.Null: value = null; break; default: throw new System.ArgumentOutOfRangeException(); } var v = new Yarn.Value(value); SetValue("$" + variable.name, v); } }
private void InitIfNeeded() { // create the main Dialogue runner, and pass our variableStorage to it varStorage = new MerinoVariableStorage(); dialogue = new Yarn.Dialogue(varStorage); // setup the logging system. dialogue.LogDebugMessage = message => MerinoDebug.Log(LoggingLevel.Verbose, message); dialogue.LogErrorMessage = PlaytestErrorLog; // icons if (errorIcon == null) { errorIcon = EditorGUIUtility.Load("icons/d_console.erroricon.sml.png") as Texture; } }
/// <summary> /// Logs errors from the playtest engine and Yarn Loader. /// </summary> public void PlaytestErrorLog(string message) { string fileName = "unknown"; string nodeName = "unknown"; int lineNumber = -1; // detect file name if (message.Contains("file")) { fileName = message.Split(new string[] { "file" }, StringSplitOptions.None)[1].Split(new string[] { " ", ":" }, StringSplitOptions.None)[1]; } // detect node name if (message.Contains("node")) { nodeName = message.Split(new string[] { "node" }, StringSplitOptions.None)[1].Split(new string[] { " ", ":" }, StringSplitOptions.None)[1]; // detect line numbers, if any, by grabbing the first digit after nodeName string numberLog = ""; for (int index = message.IndexOf(nodeName); index < message.Length; index++) { if (Char.IsDigit(message[index])) { numberLog += message[index]; } else if (numberLog.Length > 0) // did we hit a non-number, after already hitting numbers? then stop { break; } } int.TryParse(numberLog, out lineNumber); } var nodeRef = MerinoData.TreeElements.Find(x => x.name == nodeName); // v0.6, resolved: "todo: replace "<input>" in parsing errors with the name of the file instead." // if filename is default "<input>" then guess filename via cached parentID data in TreeElements if (fileName == "<input>" && nodeRef != null) { fileName = MerinoData.GetFileParent(nodeRef.id).name; } MerinoEditorWindow.errorLog.Add(new MerinoEditorWindow.MerinoErrorLine(message, fileName, nodeRef != null ? nodeRef.id : -1, Mathf.Max(0, lineNumber))); MerinoDebug.Log(LoggingLevel.Error, message); }
public static void ConvertNodesInFile(ConvertFormatOptions options, string file, string fileExtension, ConvertNodesToText convert) { // var d = new Dialogue(null); var text = File.ReadAllText(file); IEnumerable <YarnSpinnerLoader.NodeInfo> nodes; try { nodes = YarnSpinnerLoader.GetNodesFromText(text, YarnSpinnerLoader.GetFormatFromFileName(file)); } catch (FormatException e) { MerinoDebug.Log(LoggingLevel.Error, e.Message); return; } var serialisedText = convert(nodes); var destinationDirectory = options.outputDirectory; if (destinationDirectory == null) { destinationDirectory = Path.GetDirectoryName(file); } var fileName = Path.GetFileName(file); // ChangeExtension thinks that the file "Foo.yarn.txt" has the extension "txt", so // to simplify things, just lop that extension off right away if it's there fileName = fileName.Replace(".yarn.txt", ""); // change the filename's extension fileName = Path.ChangeExtension(fileName, fileExtension); // figure out where we're writing this file var destinationFilePath = Path.Combine(destinationDirectory, fileName); File.WriteAllText(destinationFilePath, serialisedText); if (options.verbose) { MerinoDebug.Log(LoggingLevel.Verbose, "Wrote " + destinationFilePath); } }
public static void SaveDataToFiles() { if (MerinoData.CurrentFiles.Count > 0) { foreach (var file in MerinoData.CurrentFiles) { if (MerinoData.FileToNodeID.ContainsKey(file)) { File.WriteAllText(AssetDatabase.GetAssetPath(file), SaveFileNodesAsString(MerinoData.FileToNodeID[file])); EditorUtility.SetDirty(file); LastSaveTime = EditorApplication.timeSinceStartup; } else { MerinoDebug.Log(LoggingLevel.Warning, file.name + " has not been mapped to a NodeID and cannot be saved, reload the file and try again."); } } } }
/// <summary> /// Logs errors from the playtest engine and Yarn Loader. /// </summary> public void PlaytestErrorLog(string message) { string fileName = "unknown"; string nodeName = "unknown"; int lineNumber = -1; // detect file name if (message.Contains("file")) { fileName = message.Split(new string[] { "file" }, StringSplitOptions.None)[1].Split(new string[] { " ", ":" }, StringSplitOptions.None)[1]; } // detect node name if (message.Contains("node")) { nodeName = message.Split(new string[] { "node" }, StringSplitOptions.None)[1].Split(new string[] { " ", ":" }, StringSplitOptions.None)[1]; // detect line numbers, if any, by grabbing the first digit after nodeName string numberLog = ""; for (int index = message.IndexOf(nodeName); index < message.Length; index++) { if (Char.IsDigit(message[index])) { numberLog += message[index]; } else if (numberLog.Length > 0) // did we hit a non-number, after already hitting numbers? then stop { break; } } int.TryParse(numberLog, out lineNumber); } // also output to Unity console var nodeRef = MerinoData.TreeElements.Find(x => x.name == nodeName); MerinoEditorWindow.errorLog.Add(new MerinoEditorWindow.MerinoErrorLine(message, fileName, nodeRef != null ? nodeRef.id : -1, Mathf.Max(0, lineNumber))); MerinoDebug.Log(LoggingLevel.Error, message); }
public static void SaveDataToFiles() { if (MerinoData.CurrentFiles.Count > 0) { for (int i = 0; i < MerinoData.CurrentFiles.Count; i++) { TextAsset file = MerinoData.CurrentFiles[i]; if (MerinoData.FileToNodeID.ContainsKey(file)) { if (file == null) { var missingDataID = MerinoData.FileToNodeID[file]; MerinoData.DirtyFiles.Remove(file); // wait how does this work? it's null, but the pointer isn't? MerinoData.CurrentFiles.RemoveAt(i); i--; var missingFileName = MerinoData.GetNode(missingDataID) != null?MerinoData.GetNode(missingDataID).name : "<cannot recover filename>"; if (EditorUtility.DisplayDialog("Can't save file " + missingFileName, "The file has been deleted, moved outside of the project's assets folder, or otherwise hidden. Merino can't find it.", "Save backup of current data as new file", "Do nothing")) { MerinoEditorWindow.GetEditorWindow().CreateNewYarnFile("YarnBackupOfFile", SaveAllNodesAsString(missingDataID)); } continue; } else { File.WriteAllText(AssetDatabase.GetAssetPath(file), SaveFileNodesAsString(MerinoData.FileToNodeID[file])); EditorUtility.SetDirty(file); LastSaveTime = EditorApplication.timeSinceStartup; } } else { MerinoDebug.Log(LoggingLevel.Warning, file.name + " has not been mapped to a NodeID and cannot be saved, reload the file and try again."); } } EditorUtility.SetDirty(MerinoData.Instance); } }
public static string ConvertNodesToYarnText(IEnumerable <YarnSpinnerLoader.NodeInfo> nodes) { var sb = new System.Text.StringBuilder(); var properties = typeof(YarnSpinnerLoader.NodeInfo).GetProperties(); foreach (var node in nodes) { foreach (var property in properties) { // ignore the body attribute if (property.Name == "body") { continue; } // piggy-back off the JsonIgnoreAttribute to sense items that should not be serialised if (property.GetCustomAttributes(typeof(JsonIgnoreAttribute), false).Length > 0) { continue; } var field = property.Name; string value; var propertyType = property.PropertyType; if (propertyType.IsAssignableFrom(typeof(string))) { value = (string)property.GetValue(node, null); // avoid storing nulls when we could store the empty string instead if (value == null) { value = ""; } } else if (propertyType.IsAssignableFrom(typeof(int))) { value = ((int)property.GetValue(node, null)).ToString(); } else if (propertyType.IsAssignableFrom(typeof(YarnSpinnerLoader.NodeInfo.Position))) { var position = (YarnSpinnerLoader.NodeInfo.Position)property.GetValue(node, null); value = string.Format("{0},{1}", position.x, position.y); } else { MerinoDebug.LogFormat(LoggingLevel.Error, "Internal error: Node {0}'s property {1} has unsupported type {2}", node.title, property.Name, propertyType.FullName); // will never be run, but prevents the compiler being mean about us not returning a value throw new Exception(); } var header = string.Format("{0}: {1}", field, value); sb.Append(MerinoPrefs.lineEnding + header); } // now write the body // 5 May 2019, changed all AppendLine to use regular Append, with manual line breaks // this is to preserve compatibility with the base Yarn Editor, which doesn't like "\r\n" (which AppendLine uses) // see: https://github.com/radiatoryang/merino/issues/26 sb.Append(MerinoPrefs.lineEnding + "---"); sb.Append(MerinoPrefs.lineEnding + node.body); sb.Append(MerinoPrefs.lineEnding + "==="); } return(sb.ToString()); }
public static IList <MerinoTreeElement> GetDataFromFile(TextAsset source, int startID = 1, bool useFastMode = false) { var treeElements = new List <MerinoTreeElement>(); if (!useFastMode) { AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(source)); // TODO: only reload assets that need it? how to do that //var format = YarnSpinnerLoader.GetFormatFromFileName(AssetDatabase.GetAssetPath(currentFile)); // TODO: add JSON and ByteCode support? } // ROOT: create a root node for the file itself var fileRoot = new MerinoTreeElement(source.name, 0, startID); fileRoot.leafType = MerinoTreeElement.LeafType.File; fileRoot.children = new List <TreeElement>(); treeElements.Add(fileRoot); if (MerinoData.FileToNodeID.ContainsKey(source)) { MerinoData.FileToNodeID[source] = startID; } else { MerinoData.FileToNodeID.Add(source, startID); } // load nodes // if there's no header sentinel in the text file, then just return an empty list if (!source.text.Contains("---")) { return(treeElements); } // otherwise, load nodes from file var nodes = YarnSpinnerLoader.GetNodesFromText(source.text, NodeFormat.Text); var parents = new Dictionary <MerinoTreeElement, string>(); foreach (var node in nodes) { // clean some of the stuff to help prevent file corruption string cleanName = MerinoUtils.CleanYarnField(node.title, true); string cleanBody = MerinoUtils.CleanYarnField(node.body); string cleanTags = MerinoUtils.CleanYarnField(node.tags, true); string cleanParent = string.IsNullOrEmpty(node.parent) ? "" : MerinoUtils.CleanYarnField(node.parent, true); // write data to the objects var newItem = new MerinoTreeElement(cleanName, 0, startID + treeElements.Count); newItem.nodeBody = cleanBody; newItem.nodePosition = new Vector2Int(node.position.x, node.position.y); newItem.nodeTags = cleanTags; if (string.IsNullOrEmpty(cleanParent) || cleanParent == "Root") { newItem.parent = fileRoot; newItem.cachedParentID = fileRoot.id; fileRoot.children.Add(newItem); } else { parents.Add(newItem, cleanParent); // we have to assign parents in a second pass later on, not right now } treeElements.Add(newItem); } // second pass: now that all nodes have been created, we can finally assign parents foreach (var kvp in parents) { var parent = treeElements.Find(x => x.name == kvp.Value); if (parent == null) { MerinoDebug.LogFormat(LoggingLevel.Error, "Merino couldn't assign parent for node {0}: can't find a parent called {1}", kvp.Key.name, kvp.Value); } else { // tell child about it's parent kvp.Key.parent = parent; kvp.Key.cachedParentID = parent.id; // tell parent about it's child if (kvp.Key.parent.children == null) // init parent's list of children if not already initialized { kvp.Key.parent.children = new List <TreeElement>(); } kvp.Key.parent.children.Add(kvp.Key); } } return(treeElements); }
// ensure unique node titles, very important for YarnSpinner private static void ValidateNodeTitles(List <MerinoTreeElement> nodes = null) { if (!MerinoPrefs.validateNodeTitles) { return; } if (nodes == null) // if null, then let's just use all currently loaded nodes { nodes = MerinoData.TreeElements; } // make sure we're not doing this to any folder or file nodes, ONLY YARN NODES nodes = nodes.Where(x => x.leafType == MerinoTreeElement.LeafType.Node).ToList(); // validate data: ensure unique node names var nodeTitles = new Dictionary <int, string>(); // index, newTitle var duplicateCount = new Dictionary <string, int>(); // increment counter for each duplicate name, and use for rename suffix bool foundDuplicate = false; for (int i = 0; i < nodes.Count; i++) { // if there's a node already with that name, then append unique suffix if (nodeTitles.Values.Contains(nodes[i].name)) { // count duplicates if (!duplicateCount.ContainsKey(nodes[i].name)) { duplicateCount.Add(nodes[i].name, 2); } // when we make a new name, we have to ensure it's unique... if (nodeTitles.ContainsKey(nodes[i].id) == false) { nodeTitles.Add(nodes[i].id, nodes[i].name); } nodeTitles[nodes[i].id] = nodes[i].name + "_" + duplicateCount[nodes[i].name]++; foundDuplicate = true; } // but if there's not already a node with that name, we should still make a note of it else if (nodeTitles.ContainsKey(nodes[i].id) == false) { nodeTitles.Add(nodes[i].id, nodes[i].name); } } if (foundDuplicate) { string renamedNodes = "Merino found nodes with duplicate names (which aren't allowed for Yarn) and renamed them. This might break node links, you can undo it. The following nodes were renamed: "; Undo.RecordObject(MerinoData.Instance, "Merino: AutoRename"); foreach (var kvp in nodeTitles) { if (MerinoData.TreeElements[kvp.Key].name != kvp.Value) { renamedNodes += string.Format("\n* {0} > {1}", MerinoData.TreeElements[kvp.Key].name, kvp.Value); MerinoData.TreeElements[kvp.Key].name = kvp.Value; } } EditorUtility.SetDirty(MerinoData.Instance); MerinoDebug.Log(LoggingLevel.Warning, renamedNodes); //todo: repaint MerinoEditorWindow tree view so names get updated // this is bad, but we're gonna do some recursion here, just to make extra sure there's STILL no duplicates... ValidateNodeTitles(MerinoData.TreeElements); } else if (MerinoPrefs.useAutosave) { SaveDataToFiles(); } }
void DrawToolbar(Event e) { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); if (validDialogue) { GUILayout.Space(2); //small space to mimic unity editor // jump to node button var jumpOptions = dialogue.allNodes.ToList(); int currentJumpIndex = jumpOptions.IndexOf(dialogue.currentNode); if (!IsDialogueRunning) { // if there is no current node, then inform the user that currentJumpIndex = 0; jumpOptions.Insert(0, "<Stopped> Jump to Node?..."); } GUI.SetNextControlName(popupControl); int newJumpIndex = EditorGUILayout.Popup( new GUIContent("", "The node you're currently playing; you can also jump to any other node."), currentJumpIndex, jumpOptions.Select(x => x.StartsWith("<Stopped>") ? new GUIContent(x) : new GUIContent("Node: " + x)).ToArray(), EditorStyles.toolbarDropDown, GUILayout.Width(160)); if (currentJumpIndex != newJumpIndex) { if (IsDialogueRunning || newJumpIndex > 0) { PlaytestFrom_Internal(jumpOptions[newJumpIndex], false); } } GUILayout.Space(6); GUI.enabled = IsDialogueRunning; // disable if dialogue isn't running // attempt to get current node var matchingNode = MerinoData.GetNode(dialogue.currentNode); if (lastFileParent > 0) // if we know the file where the playtest started, we can be more specific { matchingNode = MerinoData.GetAllCachedChildren(lastFileParent).Find(node => node.name == dialogue.currentNode); // ok if that search failed for some reason, then just give up and fallback if (matchingNode == null) { matchingNode = MerinoData.GetNode(dialogue.currentNode); } } var content = new GUIContent(" View Node Source", MerinoEditorResources.TextAsset, "Click to see Yarn script code for this node."); if (GUILayout.Button(content, EditorStyles.toolbarButton, GUILayout.Width(EditorStyles.toolbarButton.CalcSize(content).x - 40))) { if (matchingNode != null) { // display in yarn editor window GetWindow <MerinoEditorWindow>(MerinoEditorWindow.windowTitle, true). SelectNodeAndZoomToLine(matchingNode.id, GuessLineNumber(matchingNode.id, displayStringFull)); } else { MerinoDebug.LogFormat(LoggingLevel.Warning, "Merino culdn't find any node called {0}. It might've been deleted or the Yarn file is corrupted.", dialogue.currentNode); } } if (GUILayout.Button(new GUIContent(" View in Node Map", MerinoEditorResources.Nodemap, "Click to see this node in the node map window."), EditorStyles.toolbarButton)) { if (matchingNode != null) { MerinoNodemapWindow.GetNodemapWindow().FocusNode(matchingNode.id); } else { MerinoDebug.LogFormat(LoggingLevel.Warning, "Merino couldn't find any node called {0}. It might've been deleted or the Yarn file is corrupted.", dialogue.currentNode); } } GUI.enabled = true; } GUILayout.FlexibleSpace(); // playtesting preferences popup if (GUILayout.Button(new GUIContent("Preferences"), EditorStyles.toolbarDropDown)) { PopupWindow.Show(prefButtonRect, new PlaytestPrefsPopup(IsDocked())); } //grab popup button's rect do determine the height of the toolbar if (e.type == EventType.Repaint) { prefButtonRect = GUILayoutUtility.GetLastRect(); } GUILayout.Space(2); //small space to mimic unity editor EditorGUILayout.EndHorizontal(); // eat clicks if (e.type == EventType.MouseDown) { // clicked on toolbar if (e.mousePosition.y < prefButtonRect.height) { e.Use(); } // clicked out of popup if (GUI.GetNameOfFocusedControl() == popupControl) { e.Use(); GUI.FocusControl(null); } } }