#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Reflection; using UnityEditor; using UnityEngine; namespace Gyvr.Mythril2D { public class QuestBuilderWindow : EditorWindow { private const string OutputRootPrefsKey = "Mythril2D.QuestBuilder.OutputRoot"; private const string DefaultOutputRootPath = "Assets/GeneratedQuests"; private enum EBuilderTaskType { KillMonster, CollectItem, TalkToNPC, GameFlag } [Serializable] private class BuilderTask { public EBuilderTaskType type = EBuilderTaskType.KillMonster; public bool requirePreviousTaskCompletion = false; public MonsterSheet monster = null; public int monstersToKill = 1; public Item item = null; public int itemAmount = 1; public NPCSheet npc = null; [TextArea(2, 5)] public string talkDialogue = "Thank you for coming."; public string gameFlag = ""; public bool gameFlagTargetState = true; } private DefaultAsset m_outputRoot = null; private string m_questTitle = "New Quest"; private string m_description = "Quest description..."; private int m_recommendedLevel = 1; private int m_requiredLevel = 1; private bool m_repeatable = false; private NPCSheet m_offeredBy = null; private NPCSheet m_reportTo = null; [TextArea(3, 7)] private string m_offerDialogue = "Could you help me with something?"; [TextArea(2, 5)] private string m_hintDialogue = "Please continue with the task."; [TextArea(2, 5)] private string m_completedDialogue = "Thank you. You did well."; private string m_acceptOptionLabel = "Accept"; private string m_declineOptionLabel = "Not now"; private readonly List m_tasks = new(); private Vector2 m_scroll; [MenuItem("Tools/Mythril2D/Quest Builder")] public static void Open() { QuestBuilderWindow window = GetWindow("Quest Builder"); window.minSize = new Vector2(620f, 720f); window.Show(); } private void OnEnable() { if (m_tasks.Count == 0) m_tasks.Add(new BuilderTask()); LoadSavedOutputRoot(); } private void OnGUI() { m_scroll = EditorGUILayout.BeginScrollView(m_scroll); DrawHeader(); DrawQuestDetails(); DrawDialogueDetails(); DrawTasks(); EditorGUILayout.Space(18); using (new EditorGUI.DisabledScope(string.IsNullOrWhiteSpace(m_questTitle))) { if (GUILayout.Button("CREATE QUEST", GUILayout.Height(42))) CreateQuestAssets(); } EditorGUILayout.Space(20); EditorGUILayout.EndScrollView(); } private void DrawHeader() { EditorGUILayout.Space(8); EditorGUILayout.LabelField("Quest Builder", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "Creates a Quest asset, DialogueSequence assets, and QuestTask assets in one click. " + "This does not change runtime quest logic.", MessageType.Info); EditorGUI.BeginChangeCheck(); m_outputRoot = (DefaultAsset)EditorGUILayout.ObjectField( "Output Root Folder", m_outputRoot, typeof(DefaultAsset), false); if (EditorGUI.EndChangeCheck()) SaveOutputRoot(); if (m_outputRoot == null) { EditorGUILayout.HelpBox( "No output folder selected. The builder will use Assets/GeneratedQuests.", MessageType.None); } EditorGUILayout.Space(8); } private void DrawQuestDetails() { EditorGUILayout.LabelField("Quest Details", EditorStyles.boldLabel); m_questTitle = EditorGUILayout.TextField("Quest Title", m_questTitle); m_description = EditorGUILayout.TextArea(m_description, GUILayout.MinHeight(60)); m_recommendedLevel = EditorGUILayout.IntSlider( "Recommended Level", m_recommendedLevel, Constants.MinLevel, Constants.MaxLevel); m_requiredLevel = EditorGUILayout.IntSlider( "Required Level", m_requiredLevel, Constants.MinLevel, Constants.MaxLevel); m_repeatable = EditorGUILayout.Toggle("Repeatable", m_repeatable); EditorGUILayout.Space(6); m_offeredBy = (NPCSheet)EditorGUILayout.ObjectField( "Offered By", m_offeredBy, typeof(NPCSheet), false); m_reportTo = (NPCSheet)EditorGUILayout.ObjectField( "Report To", m_reportTo, typeof(NPCSheet), false); if (m_offeredBy == null) { EditorGUILayout.HelpBox( "Offered By is usually needed so the NPC can offer/start this quest.", MessageType.Warning); } if (m_reportTo == null) { EditorGUILayout.HelpBox( "Report To is usually needed so the NPC can complete/turn in this quest.", MessageType.Warning); } EditorGUILayout.Space(12); } private void DrawDialogueDetails() { EditorGUILayout.LabelField("Dialogues", EditorStyles.boldLabel); EditorGUILayout.LabelField("Offer Dialogue"); m_offerDialogue = EditorGUILayout.TextArea(m_offerDialogue, GUILayout.MinHeight(70)); EditorGUILayout.BeginHorizontal(); m_acceptOptionLabel = EditorGUILayout.TextField("Accept Option", m_acceptOptionLabel); m_declineOptionLabel = EditorGUILayout.TextField("Decline Option", m_declineOptionLabel); EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(4); EditorGUILayout.LabelField("Hint / In-progress Dialogue"); m_hintDialogue = EditorGUILayout.TextArea(m_hintDialogue, GUILayout.MinHeight(50)); EditorGUILayout.Space(4); EditorGUILayout.LabelField("Completed Dialogue"); m_completedDialogue = EditorGUILayout.TextArea(m_completedDialogue, GUILayout.MinHeight(50)); EditorGUILayout.Space(12); } private void DrawTasks() { EditorGUILayout.LabelField("Tasks", EditorStyles.boldLabel); for (int i = 0; i < m_tasks.Count; i++) { BuilderTask task = m_tasks[i]; EditorGUILayout.BeginVertical("box"); EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField($"Task {i + 1}", EditorStyles.boldLabel); if (GUILayout.Button("Duplicate", GUILayout.Width(85))) { m_tasks.Insert(i + 1, CloneTask(task)); GUIUtility.ExitGUI(); } using (new EditorGUI.DisabledScope(m_tasks.Count <= 1)) { if (GUILayout.Button("Remove", GUILayout.Width(75))) { m_tasks.RemoveAt(i); GUIUtility.ExitGUI(); } } EditorGUILayout.EndHorizontal(); task.type = (EBuilderTaskType)EditorGUILayout.EnumPopup("Type", task.type); task.requirePreviousTaskCompletion = EditorGUILayout.Toggle( "Wait For Previous Task", task.requirePreviousTaskCompletion); switch (task.type) { case EBuilderTaskType.KillMonster: task.monster = (MonsterSheet)EditorGUILayout.ObjectField( "Monster", task.monster, typeof(MonsterSheet), false); task.monstersToKill = Mathf.Max( 1, EditorGUILayout.IntField("Amount", task.monstersToKill)); break; case EBuilderTaskType.CollectItem: task.item = (Item)EditorGUILayout.ObjectField( "Item", task.item, typeof(Item), false); task.itemAmount = Mathf.Max( 1, EditorGUILayout.IntField("Amount", task.itemAmount)); break; case EBuilderTaskType.TalkToNPC: task.npc = (NPCSheet)EditorGUILayout.ObjectField( "Target NPC", task.npc, typeof(NPCSheet), false); EditorGUILayout.LabelField("Talk Dialogue"); task.talkDialogue = EditorGUILayout.TextArea( task.talkDialogue, GUILayout.MinHeight(45)); break; case EBuilderTaskType.GameFlag: task.gameFlag = EditorGUILayout.TextField("Game Flag", task.gameFlag); task.gameFlagTargetState = EditorGUILayout.Toggle( "Required State", task.gameFlagTargetState); break; } EditorGUILayout.EndVertical(); EditorGUILayout.Space(4); } if (GUILayout.Button("+ Add Task", GUILayout.Height(28))) m_tasks.Add(new BuilderTask()); EditorGUILayout.Space(12); } private BuilderTask CloneTask(BuilderTask source) { return new BuilderTask { type = source.type, requirePreviousTaskCompletion = source.requirePreviousTaskCompletion, monster = source.monster, monstersToKill = source.monstersToKill, item = source.item, itemAmount = source.itemAmount, npc = source.npc, talkDialogue = source.talkDialogue, gameFlag = source.gameFlag, gameFlagTargetState = source.gameFlagTargetState }; } private void CreateQuestAssets() { string root = EnsureFolderPath(GetOutputRootPath()); string safeQuestName = SanitizeAssetName(m_questTitle); string questFolder = CreateUniqueFolder(root, safeQuestName); string dialogueFolder = EnsureChildFolder(questFolder, "Dialogues"); string tasksFolder = EnsureChildFolder(questFolder, "Tasks"); DialogueSequence offerDialogue = CreateDialogueAsset( dialogueFolder, $"{safeQuestName}_OfferDialogue", m_offerDialogue, true); DialogueSequence hintDialogue = CreateDialogueAsset( dialogueFolder, $"{safeQuestName}_HintDialogue", m_hintDialogue, false); DialogueSequence completedDialogue = CreateDialogueAsset( dialogueFolder, $"{safeQuestName}_CompletedDialogue", m_completedDialogue, false); List createdTasks = new(); for (int i = 0; i < m_tasks.Count; i++) { QuestTask task = CreateTaskAsset( tasksFolder, dialogueFolder, safeQuestName, i, m_tasks[i]); if (task != null) createdTasks.Add(task); } Quest quest = CreateInstance(); quest.name = safeQuestName; SetMember(quest, "m_title", m_questTitle); SetMember(quest, "m_description", m_description); SetMember(quest, "m_recommendedLevel", Mathf.Clamp(m_recommendedLevel, Constants.MinLevel, Constants.MaxLevel)); SetMember(quest, "m_requiredLevel", Mathf.Clamp(m_requiredLevel, Constants.MinLevel, Constants.MaxLevel)); SetMember(quest, "m_repeatable", m_repeatable); SetMember(quest, "m_tasks", createdTasks.ToArray()); SetMember(quest, "m_offeredBy", m_offeredBy); SetMember(quest, "m_reportTo", m_reportTo); SetMember(quest, "m_questOfferDialogue", offerDialogue); SetMember(quest, "m_questHintDialogue", hintDialogue); SetMember(quest, "m_questCompletedDialogue", completedDialogue); string questPath = AssetDatabase.GenerateUniqueAssetPath($"{questFolder}/{safeQuestName}.asset"); AssetDatabase.CreateAsset(quest, questPath); EditorUtility.SetDirty(quest); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Selection.activeObject = quest; EditorGUIUtility.PingObject(quest); Debug.Log($"[QuestBuilder] Created quest: {questPath}", quest); EditorUtility.DisplayDialog( "Quest Created", $"Created quest:\n{m_questTitle}\n\nFolder:\n{questFolder}\n\nImportant: if your DatabaseRegistry does not auto-detect new DatabaseEntry assets, add/refresh the generated Quest and QuestTask assets in your database.", "Nice"); } private QuestTask CreateTaskAsset( string tasksFolder, string dialogueFolder, string questSafeName, int index, BuilderTask data) { QuestTask task = null; string prefix = $"{questSafeName}_Task_{index + 1:00}"; switch (data.type) { case EBuilderTaskType.KillMonster: { KillMonsterTask killTask = CreateInstance(); killTask.monster = data.monster; killTask.monstersToKill = Mathf.Max(1, data.monstersToKill); task = killTask; break; } case EBuilderTaskType.CollectItem: { ItemTask itemTask = CreateInstance(); itemTask.item = data.item; itemTask.amountToCollect = Mathf.Max(1, data.itemAmount); task = itemTask; break; } case EBuilderTaskType.TalkToNPC: { TalkToNPCTask talkTask = CreateInstance(); talkTask.target = data.npc; talkTask.dialogue = CreateDialogueAsset( dialogueFolder, $"{prefix}_TalkDialogue", data.talkDialogue, false); task = talkTask; break; } case EBuilderTaskType.GameFlag: { GameFlagTask flagTask = CreateInstance(); TrySetGameFlagTaskValue( flagTask, data.gameFlag, data.gameFlagTargetState); task = flagTask; break; } } if (task == null) return null; task.name = prefix; SetMember(task, "m_requirePreviousTasksCompletion", data.requirePreviousTaskCompletion); string path = AssetDatabase.GenerateUniqueAssetPath($"{tasksFolder}/{prefix}_{data.type}.asset"); AssetDatabase.CreateAsset(task, path); EditorUtility.SetDirty(task); return task; } private static void TrySetGameFlagTaskValue(GameFlagTask flagTask, string flagName, bool targetState) { if (flagTask == null || string.IsNullOrWhiteSpace(flagName)) return; flagName = flagName.Trim(); FieldInfo field = FindFieldDeep(typeof(GameFlagTask), "gameFlags"); if (field == null) { Debug.LogWarning("[QuestBuilder] Could not find GameFlagTask.gameFlags field."); return; } object dictionaryObject = field.GetValue(flagTask); if (dictionaryObject == null) { try { dictionaryObject = Activator.CreateInstance(field.FieldType); field.SetValue(flagTask, dictionaryObject); } catch (Exception ex) { Debug.LogWarning($"[QuestBuilder] Could not create gameFlags dictionary: {ex.Message}"); return; } } if (dictionaryObject is System.Collections.IDictionary dictionary) { dictionary[flagName] = targetState; return; } PropertyInfo indexer = field.FieldType.GetProperty("Item", new[] { typeof(string) }); if (indexer != null && indexer.CanWrite) { try { indexer.SetValue(dictionaryObject, targetState, new object[] { flagName }); return; } catch (Exception ex) { Debug.LogWarning($"[QuestBuilder] Could not set game flag through indexer: {ex.Message}"); return; } } Debug.LogWarning( "[QuestBuilder] gameFlags exists, but it is not assignable through IDictionary or a string indexer."); } private DialogueSequence CreateDialogueAsset(string folder, string assetName, string text, bool questOffer) { if (string.IsNullOrWhiteSpace(text)) return null; DialogueSequence sequence = CreateInstance(); sequence.name = SanitizeAssetName(assetName); ConfigureDialogueSequence(sequence, SplitLines(text), questOffer); string path = AssetDatabase.GenerateUniqueAssetPath($"{folder}/{sequence.name}.asset"); AssetDatabase.CreateAsset(sequence, path); EditorUtility.SetDirty(sequence); return sequence; } private string[] SplitLines(string text) { if (string.IsNullOrWhiteSpace(text)) return Array.Empty(); string normalized = text.Replace("\r\n", "\n").Replace("\r", "\n"); string[] raw = normalized.Split('\n'); List result = new(); for (int i = 0; i < raw.Length; i++) { string line = raw[i].Trim(); if (!string.IsNullOrWhiteSpace(line)) result.Add(line); } if (result.Count == 0) result.Add(text.Trim()); return result.ToArray(); } private void ConfigureDialogueSequence(DialogueSequence sequence, string[] lines, bool questOffer) { SetMember(sequence, "lines", lines); SetMember(sequence, "m_lines", lines); if (questOffer) { Array options = CreateQuestOfferOptions(); SetMember(sequence, "options", options); SetMember(sequence, "m_options", options); } else { Array options = Array.CreateInstance(typeof(DialogueSequenceOption), 0); SetMember(sequence, "options", options); SetMember(sequence, "m_options", options); } } private Array CreateQuestOfferOptions() { Type optionType = typeof(DialogueSequenceOption); Array options = Array.CreateInstance(optionType, 2); object accept = CreateDialogueOption(optionType, m_acceptOptionLabel, true); object decline = CreateDialogueOption(optionType, m_declineOptionLabel, false); options.SetValue(accept, 0); options.SetValue(decline, 1); return options; } private object CreateDialogueOption(Type optionType, string label, bool sendsAcceptMessage) { object option = Activator.CreateInstance(optionType); string safeLabel = string.IsNullOrWhiteSpace(label) ? "Continue" : label.Trim(); SetMember(option, "name", safeLabel); SetMember(option, "m_name", safeLabel); SetMember(option, "sequence", null); SetMember(option, "m_sequence", null); SetMember(option, "node", null); SetMember(option, "m_node", null); if (sendsAcceptMessage) TrySetAcceptMessage(option); return option; } private void TrySetAcceptMessage(object option) { if (option == null) return; FieldInfo field = FindFieldDeep(option.GetType(), "message") ?? FindFieldDeep(option.GetType(), "m_message"); if (field != null) { object message = CreateDialogueMessageValue(field.FieldType, EDialogueMessageType.Accept); if (message != null || !field.FieldType.IsValueType) { field.SetValue(option, message); return; } } PropertyInfo property = FindPropertyDeep(option.GetType(), "message") ?? FindPropertyDeep(option.GetType(), "m_message"); if (property != null && property.CanWrite) { object message = CreateDialogueMessageValue(property.PropertyType, EDialogueMessageType.Accept); if (message != null || !property.PropertyType.IsValueType) property.SetValue(option, message); } } private object CreateDialogueMessageValue(Type targetType, EDialogueMessageType messageType) { if (targetType == null) return null; if (targetType == typeof(EDialogueMessageType)) return messageType; if (targetType.IsEnum) { try { return Enum.Parse(targetType, messageType.ToString(), true); } catch { return null; } } MethodInfo[] methods = targetType.GetMethods(BindingFlags.Public | BindingFlags.Static); for (int i = 0; i < methods.Length; i++) { MethodInfo method = methods[i]; if (method.Name != "op_Implicit" && method.Name != "op_Explicit") continue; ParameterInfo[] parameters = method.GetParameters(); if (method.ReturnType == targetType && parameters.Length == 1 && parameters[0].ParameterType == typeof(EDialogueMessageType)) { try { return method.Invoke(null, new object[] { messageType }); } catch { } } } ConstructorInfo[] constructors = targetType.GetConstructors( BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); for (int i = 0; i < constructors.Length; i++) { ConstructorInfo constructor = constructors[i]; ParameterInfo[] parameters = constructor.GetParameters(); if (parameters.Length == 1 && parameters[0].ParameterType == typeof(EDialogueMessageType)) { try { return constructor.Invoke(new object[] { messageType }); } catch { } } } object instance = null; try { instance = Activator.CreateInstance(targetType, true); } catch { return null; } SetMember(instance, "type", messageType); SetMember(instance, "m_type", messageType); SetMember(instance, "messageType", messageType); SetMember(instance, "m_messageType", messageType); return instance; } private string GetOutputRootPath() { if (m_outputRoot != null) { string path = AssetDatabase.GetAssetPath(m_outputRoot); if (!string.IsNullOrWhiteSpace(path) && AssetDatabase.IsValidFolder(path)) return path; } return DefaultOutputRootPath; } private void LoadSavedOutputRoot() { string savedPath = EditorPrefs.GetString(OutputRootPrefsKey, string.Empty); if (!string.IsNullOrWhiteSpace(savedPath) && AssetDatabase.IsValidFolder(savedPath)) { UnityEngine.Object savedFolder = AssetDatabase.LoadAssetAtPath(savedPath); if (savedFolder is DefaultAsset savedAsset) { m_outputRoot = savedAsset; return; } } UnityEngine.Object defaultFolder = AssetDatabase.LoadAssetAtPath("Assets"); if (defaultFolder is DefaultAsset defaultAsset) m_outputRoot = defaultAsset; } private void SaveOutputRoot() { if (m_outputRoot == null) { EditorPrefs.DeleteKey(OutputRootPrefsKey); return; } string path = AssetDatabase.GetAssetPath(m_outputRoot); if (!string.IsNullOrWhiteSpace(path) && AssetDatabase.IsValidFolder(path)) EditorPrefs.SetString(OutputRootPrefsKey, path); } private string EnsureFolderPath(string folderPath) { folderPath = folderPath.Replace("\\", "/").Trim('/'); if (AssetDatabase.IsValidFolder(folderPath)) return folderPath; string[] parts = folderPath.Split('/'); string current = parts[0]; for (int i = 1; i < parts.Length; i++) { string next = $"{current}/{parts[i]}"; if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(current, parts[i]); current = next; } return folderPath; } private string CreateUniqueFolder(string parentFolder, string wantedName) { parentFolder = EnsureFolderPath(parentFolder); string baseName = SanitizeAssetName(wantedName); string candidate = $"{parentFolder}/{baseName}"; int suffix = 2; while (AssetDatabase.IsValidFolder(candidate)) { candidate = $"{parentFolder}/{baseName}_{suffix}"; suffix++; } AssetDatabase.CreateFolder(parentFolder, Path.GetFileName(candidate)); return candidate; } private string EnsureChildFolder(string parentFolder, string childName) { parentFolder = EnsureFolderPath(parentFolder); string path = $"{parentFolder}/{childName}"; if (!AssetDatabase.IsValidFolder(path)) AssetDatabase.CreateFolder(parentFolder, childName); return path; } private string SanitizeAssetName(string value) { if (string.IsNullOrWhiteSpace(value)) value = "NewQuest"; foreach (char c in Path.GetInvalidFileNameChars()) value = value.Replace(c.ToString(), ""); value = value.Trim(); if (string.IsNullOrWhiteSpace(value)) value = "NewQuest"; return value.Replace(" ", "_"); } private static bool SetMember(object target, string name, object value) { if (target == null || string.IsNullOrWhiteSpace(name)) return false; Type type = target.GetType(); FieldInfo field = FindFieldDeep(type, name); if (field != null) { try { if (value == null) { if (!field.FieldType.IsValueType || Nullable.GetUnderlyingType(field.FieldType) != null) { field.SetValue(target, null); return true; } return false; } if (field.FieldType.IsAssignableFrom(value.GetType())) { field.SetValue(target, value); return true; } if (TryConvertValue(value, field.FieldType, out object convertedFieldValue)) { field.SetValue(target, convertedFieldValue); return true; } } catch { } } PropertyInfo property = FindPropertyDeep(type, name); if (property != null && property.CanWrite) { try { if (value == null) { if (!property.PropertyType.IsValueType || Nullable.GetUnderlyingType(property.PropertyType) != null) { property.SetValue(target, null); return true; } return false; } if (property.PropertyType.IsAssignableFrom(value.GetType())) { property.SetValue(target, value); return true; } if (TryConvertValue(value, property.PropertyType, out object convertedPropertyValue)) { property.SetValue(target, convertedPropertyValue); return true; } } catch { } } return false; } private static bool TryConvertValue(object value, Type targetType, out object converted) { converted = null; if (value == null || targetType == null) return false; Type valueType = value.GetType(); if (targetType.IsAssignableFrom(valueType)) { converted = value; return true; } if (targetType.IsEnum && value is string stringValue) { try { converted = Enum.Parse(targetType, stringValue, true); return true; } catch { return false; } } if (targetType.IsEnum && valueType.IsEnum) { try { converted = Enum.Parse(targetType, value.ToString(), true); return true; } catch { return false; } } return false; } private static FieldInfo FindFieldDeep(Type type, string name) { const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; while (type != null) { FieldInfo field = type.GetField(name, flags); if (field != null) return field; type = type.BaseType; } return null; } private static PropertyInfo FindPropertyDeep(Type type, string name) { const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; while (type != null) { PropertyInfo property = type.GetProperty(name, flags); if (property != null) return property; type = type.BaseType; } return null; } } } #endif