// File: QuestBuilderWindow.cs #if UNITY_EDITOR using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Reflection; using UnityEditor; using UnityEngine; namespace Gyvr.Mythril2D { public class QuestBuilderWindow : EditorWindow { private const string OutputRootPrefsKey = "QuestBuilder.OutputRoot"; private const string AutoSplitDialoguePrefsKey = "QuestBuilder.AutoSplitDialogue"; private const string DialoguePrefabPrefsKey = "QuestBuilder.DialoguePrefab"; private const string DefaultOutputRootPath = "Assets"; private const string DefaultDialoguePrefabPath = "Assets/Demo/Prefabs/UI/Overlay/Dialogue.prefab"; private const float FallbackDialogueTextWidth = 870f; private const float FallbackDialogueTextHeight = 112f; private const float FallbackDialogueFontSize = 48f; private const float TaskObjectFieldMinWidth = 220f; private const float TaskObjectFieldMaxWidth = 420f; 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_acceptDialogue = "Thank you. I knew I could count on you."; [TextArea(2, 5)] private string m_declineDialogue = "I understand. Come back if you change your mind."; [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 bool m_autoSplitDialogue = true; private GameObject m_dialoguePrefab = null; private readonly List m_tasks = new(); private Vector2 m_scroll; private DialogueFitContext m_cachedDialogueFitContext; private GameObject m_cachedDialoguePrefab = null; private bool m_dialogueFitCacheValid = false; private struct DialogueFitContext { public Component text; public float width; public float height; public float fontSize; public bool usedPrefabMetrics; public string sourceName; } private struct DialogueSplitInfo { public int pageCount; public int autoBreakCount; public bool usedPrefabMetrics; public float width; public float height; public float fontSize; public string sourceName; } [MenuItem("Tools/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(); LoadSavedDialogueSettings(); } 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))) { if (ValidateQuestSetup(out string validationError)) { CreateQuestAssets(); } else { EditorUtility.DisplayDialog( "Quest Builder Validation", validationError, "OK"); } } } 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.", 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); DrawDialogueSplitSettings(); m_offerDialogue = DrawDialogueTextArea( "Offer Dialogue", m_offerDialogue, 70); EditorGUILayout.BeginHorizontal(); m_acceptOptionLabel = EditorGUILayout.TextField("Accept Option", m_acceptOptionLabel); m_declineOptionLabel = EditorGUILayout.TextField("Decline Option", m_declineOptionLabel); EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(4); m_acceptDialogue = DrawDialogueTextArea( "Accept Response Dialogue", m_acceptDialogue, 50); EditorGUILayout.Space(4); m_declineDialogue = DrawDialogueTextArea( "Decline Response Dialogue", m_declineDialogue, 50); EditorGUILayout.Space(4); m_hintDialogue = DrawDialogueTextArea( "Hint / In-progress Dialogue", m_hintDialogue, 50); EditorGUILayout.Space(4); m_completedDialogue = DrawDialogueTextArea( "Completed Dialogue", m_completedDialogue, 50); EditorGUILayout.Space(12); } private void DrawDialogueSplitSettings() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); EditorGUI.BeginChangeCheck(); m_autoSplitDialogue = EditorGUILayout.ToggleLeft( "Auto-split dialogue that would overflow the runtime dialogue box", m_autoSplitDialogue); using (new EditorGUI.DisabledScope(!m_autoSplitDialogue)) { m_dialoguePrefab = (GameObject)EditorGUILayout.ObjectField( "Dialogue UI Prefab", m_dialoguePrefab, typeof(GameObject), false); } bool changed = EditorGUI.EndChangeCheck(); if (changed) { InvalidateDialogueFitCache(); SaveDialogueSettings(); } EditorGUILayout.BeginHorizontal(); if (GUILayout.Button("Use Default Dialogue Prefab", GUILayout.Width(190))) { m_dialoguePrefab = AssetDatabase.LoadAssetAtPath(DefaultDialoguePrefabPath); InvalidateDialogueFitCache(); SaveDialogueSettings(); } DialogueFitContext fit = GetDialogueFitContext(); string source = string.IsNullOrWhiteSpace(fit.sourceName) ? "fallback" : fit.sourceName; EditorGUILayout.LabelField( $"Box {fit.width:0}x{fit.height:0}, font {fit.fontSize:0.#} ({source})", EditorStyles.miniLabel); EditorGUILayout.EndHorizontal(); if (!fit.usedPrefabMetrics) { EditorGUILayout.HelpBox( "No valid UIDialogueMessageBox text component was found on the prefab. " + "The builder will use conservative fallback measurements.", MessageType.Warning); } EditorGUILayout.EndVertical(); EditorGUILayout.Space(4); } private string DrawDialogueTextArea(string label, string value, float minHeight) { EditorGUILayout.LabelField(label); value = EditorGUILayout.TextArea( value, GUILayout.MinHeight(minHeight)); DrawDialoguePagePreview(value); return value; } private void DrawDialoguePagePreview(string text) { string[] pages = SplitLines(text, out DialogueSplitInfo info); if (pages.Length == 0) return; GUIStyle style = new GUIStyle(EditorStyles.miniLabel); if (info.autoBreakCount > 0) style.normal.textColor = new Color(0.95f, 0.58f, 0.18f); string pageWord = info.pageCount == 1 ? "page" : "pages"; string splitText = info.autoBreakCount > 0 ? $" - auto-split {info.autoBreakCount} time(s)" : ""; EditorGUILayout.LabelField( $"Creates {info.pageCount} dialogue {pageWord}{splitText}.", style); } 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, GUILayout.Width(56)); task.type = (EBuilderTaskType)EditorGUILayout.EnumPopup(task.type, GUILayout.Width(130)); task.requirePreviousTaskCompletion = GUILayout.Toggle( task.requirePreviousTaskCompletion, "After prev", GUILayout.Width(82)); GUILayout.FlexibleSpace(); using (new EditorGUI.DisabledScope(i <= 0)) { if (GUILayout.Button("Up", GUILayout.Width(32))) { (m_tasks[i - 1], m_tasks[i]) = (m_tasks[i], m_tasks[i - 1]); GUIUtility.ExitGUI(); } } using (new EditorGUI.DisabledScope(i >= m_tasks.Count - 1)) { if (GUILayout.Button("Dn", GUILayout.Width(32))) { (m_tasks[i + 1], m_tasks[i]) = (m_tasks[i], m_tasks[i + 1]); GUIUtility.ExitGUI(); } } if (GUILayout.Button("Dup", GUILayout.Width(38))) { m_tasks.Insert(i + 1, CloneTask(task)); GUIUtility.ExitGUI(); } using (new EditorGUI.DisabledScope(m_tasks.Count <= 1)) { if (GUILayout.Button("X", GUILayout.Width(24))) { m_tasks.RemoveAt(i); GUIUtility.ExitGUI(); } } EditorGUILayout.EndHorizontal(); switch (task.type) { case EBuilderTaskType.KillMonster: EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("Monster", GUILayout.Width(70)); task.monster = (MonsterSheet)EditorGUILayout.ObjectField( task.monster, typeof(MonsterSheet), false, GUILayout.Width(GetCompactTaskObjectFieldWidth())); GUILayout.Space(8); EditorGUILayout.LabelField("Qty", GUILayout.Width(24)); task.monstersToKill = Mathf.Max( 1, EditorGUILayout.IntField(task.monstersToKill, GUILayout.Width(52))); GUILayout.FlexibleSpace(); EditorGUILayout.EndHorizontal(); break; case EBuilderTaskType.CollectItem: EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("Item", GUILayout.Width(70)); task.item = (Item)EditorGUILayout.ObjectField( task.item, typeof(Item), false, GUILayout.Width(GetCompactTaskObjectFieldWidth())); GUILayout.Space(8); EditorGUILayout.LabelField("Qty", GUILayout.Width(24)); task.itemAmount = Mathf.Max( 1, EditorGUILayout.IntField(task.itemAmount, GUILayout.Width(52))); GUILayout.FlexibleSpace(); EditorGUILayout.EndHorizontal(); break; case EBuilderTaskType.TalkToNPC: EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("NPC", GUILayout.Width(70)); task.npc = (NPCSheet)EditorGUILayout.ObjectField( task.npc, typeof(NPCSheet), false, GUILayout.Width(GetCompactTaskObjectFieldWidth())); GUILayout.FlexibleSpace(); EditorGUILayout.EndHorizontal(); task.talkDialogue = DrawDialogueTextArea( "Talk Dialogue", task.talkDialogue, 45); break; case EBuilderTaskType.GameFlag: EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("Flag", GUILayout.Width(70)); task.gameFlag = EditorGUILayout.TextField( task.gameFlag, GUILayout.Width(GetCompactTaskObjectFieldWidth())); GUILayout.Space(8); task.gameFlagTargetState = GUILayout.Toggle( task.gameFlagTargetState, "Required", GUILayout.Width(78)); GUILayout.FlexibleSpace(); EditorGUILayout.EndHorizontal(); break; } EditorGUILayout.EndVertical(); EditorGUILayout.Space(4); } if (GUILayout.Button("+ Add Task", GUILayout.Height(28))) m_tasks.Add(new BuilderTask()); EditorGUILayout.Space(12); } private float GetCompactTaskObjectFieldWidth() { return Mathf.Clamp( position.width - 205f, TaskObjectFieldMinWidth, TaskObjectFieldMaxWidth); } 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 bool ValidateQuestSetup(out string error) { List errors = new(); if (string.IsNullOrWhiteSpace(m_questTitle)) errors.Add("Quest Title is empty."); if (string.IsNullOrWhiteSpace(m_description)) errors.Add("Quest Description is empty."); if (string.IsNullOrWhiteSpace(m_offerDialogue)) errors.Add("Offer Dialogue is empty."); if (string.IsNullOrWhiteSpace(m_completedDialogue)) errors.Add("Completed Dialogue is empty."); if (m_tasks == null || m_tasks.Count == 0) { errors.Add("The quest needs at least one task."); } else { for (int i = 0; i < m_tasks.Count; i++) { BuilderTask task = m_tasks[i]; if (task == null) { errors.Add($"Task {i + 1} is missing."); continue; } switch (task.type) { case EBuilderTaskType.KillMonster: if (task.monster == null) errors.Add($"Task {i + 1} is KillMonster, but no Monster is assigned."); if (task.monstersToKill <= 0) errors.Add($"Task {i + 1} is KillMonster, but Amount must be at least 1."); break; case EBuilderTaskType.CollectItem: if (task.item == null) errors.Add($"Task {i + 1} is CollectItem, but no Item is assigned."); if (task.itemAmount <= 0) errors.Add($"Task {i + 1} is CollectItem, but Amount must be at least 1."); break; case EBuilderTaskType.TalkToNPC: if (task.npc == null) errors.Add($"Task {i + 1} is TalkToNPC, but no Target NPC is assigned."); if (string.IsNullOrWhiteSpace(task.talkDialogue)) errors.Add($"Task {i + 1} is TalkToNPC, but Talk Dialogue is empty."); break; case EBuilderTaskType.GameFlag: if (string.IsNullOrWhiteSpace(task.gameFlag)) errors.Add($"Task {i + 1} is GameFlag, but Game Flag is empty."); break; } } } if (errors.Count > 0) { error = string.Join("\n", errors); return false; } error = string.Empty; return true; } 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 acceptDialogue = CreateDialogueAsset( dialogueFolder, $"{safeQuestName}_AcceptDialogue", m_acceptDialogue, false); DialogueSequence declineDialogue = CreateDialogueAsset( dialogueFolder, $"{safeQuestName}_DeclineDialogue", m_declineDialogue, false); DialogueSequence offerDialogue = CreateDialogueAsset( dialogueFolder, $"{safeQuestName}_OfferDialogue", m_offerDialogue, true, acceptDialogue, declineDialogue); 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, DialogueSequence acceptOptionSequence = null, DialogueSequence declineOptionSequence = null) { if (string.IsNullOrWhiteSpace(text)) return null; DialogueSequence sequence = CreateInstance(); sequence.name = SanitizeAssetName(assetName); ConfigureDialogueSequence( sequence, SplitLines(text), questOffer, acceptOptionSequence, declineOptionSequence); string path = AssetDatabase.GenerateUniqueAssetPath($"{folder}/{sequence.name}.asset"); AssetDatabase.CreateAsset(sequence, path); EditorUtility.SetDirty(sequence); return sequence; } private string[] SplitLines(string text) { return SplitLines(text, out _); } private string[] SplitLines(string text, out DialogueSplitInfo info) { info = default; if (string.IsNullOrWhiteSpace(text)) return Array.Empty(); string normalized = text.Replace("\r\n", "\n").Replace("\r", "\n"); string[] raw = normalized.Split('\n'); if (!m_autoSplitDialogue) { string[] manualLines = SplitManualDialogueLines(raw, text); info.pageCount = manualLines.Length; FillSplitInfoContext(ref info); return manualLines; } DialogueFitContext fit = GetDialogueFitContext(); List result = new(); int autoBreakCount = 0; for (int i = 0; i < raw.Length; i++) { string line = raw[i].Trim(); if (!string.IsNullOrWhiteSpace(line)) AppendFittedDialoguePages(line, fit, result, ref autoBreakCount); } if (result.Count == 0) result.Add(text.Trim()); info.pageCount = result.Count; info.autoBreakCount = autoBreakCount; FillSplitInfoContext(ref info, fit); return result.ToArray(); } private string[] SplitManualDialogueLines(string[] rawLines, string originalText) { List result = new(); for (int i = 0; i < rawLines.Length; i++) { string line = rawLines[i].Trim(); if (!string.IsNullOrWhiteSpace(line)) result.Add(line); } if (result.Count == 0) result.Add(originalText.Trim()); return result.ToArray(); } private void AppendFittedDialoguePages( string paragraph, DialogueFitContext fit, List result, ref int autoBreakCount) { paragraph = CollapseDialogueWhitespace(paragraph); if (string.IsNullOrWhiteSpace(paragraph)) return; string[] words = paragraph.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); string current = string.Empty; for (int i = 0; i < words.Length; i++) { string word = words[i]; string candidate = string.IsNullOrEmpty(current) ? word : $"{current} {word}"; if (DialogueTextFits(candidate, fit)) { current = candidate; continue; } if (!string.IsNullOrEmpty(current)) { result.Add(current); autoBreakCount++; current = string.Empty; } if (DialogueTextFits(word, fit)) { current = word; continue; } AppendOversizedDialogueToken(word, fit, result, ref current, ref autoBreakCount); } if (!string.IsNullOrWhiteSpace(current)) result.Add(current.Trim()); } private void AppendOversizedDialogueToken( string token, DialogueFitContext fit, List result, ref string current, ref int autoBreakCount) { TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator(token); while (enumerator.MoveNext()) { string element = enumerator.GetTextElement(); string candidate = current + element; if (DialogueTextFits(candidate, fit)) { current = candidate; continue; } if (!string.IsNullOrEmpty(current)) { result.Add(current); autoBreakCount++; current = string.Empty; } current = element; } } private string CollapseDialogueWhitespace(string text) { if (string.IsNullOrWhiteSpace(text)) return string.Empty; string[] parts = text .Replace('\t', ' ') .Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); return string.Join(" ", parts).Trim(); } private bool DialogueTextFits(string text, DialogueFitContext fit) { if (string.IsNullOrWhiteSpace(text)) return true; if (fit.text != null) { if (TryGetPreferredTextSize(fit.text, text, fit.width, out Vector2 preferred)) { bool preferredFits = !float.IsNaN(preferred.x) && !float.IsNaN(preferred.y) && preferred.x <= fit.width + 1f && preferred.y <= fit.height * 0.99f; return preferredFits && EstimateDialogueTextFits(text, fit); } return EstimateDialogueTextFits(text, fit); } return EstimateDialogueTextFits(text, fit); } private bool EstimateDialogueTextFits(string text, DialogueFitContext fit) { float fontSize = Mathf.Max(8f, fit.fontSize); float averageCharacterWidth = fontSize * 0.30f; int maxCharactersPerLine = Mathf.Max(8, Mathf.FloorToInt(fit.width / averageCharacterWidth)); int maxLines = Mathf.Max(1, Mathf.FloorToInt((fit.height * 0.99f) / fontSize)); int lineCount = 1; int currentLineLength = 0; string[] words = text.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); for (int i = 0; i < words.Length; i++) { int wordLength = Mathf.Max(1, new StringInfo(words[i]).LengthInTextElements); if (wordLength > maxCharactersPerLine) { if (currentLineLength > 0) lineCount++; int remaining = wordLength; int additionalLines = Mathf.Max(0, Mathf.CeilToInt(remaining / (float)maxCharactersPerLine) - 1); lineCount += additionalLines; currentLineLength = remaining % maxCharactersPerLine; if (currentLineLength == 0) currentLineLength = maxCharactersPerLine; if (lineCount > maxLines) return false; continue; } int nextLength = currentLineLength == 0 ? wordLength : currentLineLength + 1 + wordLength; if (nextLength <= maxCharactersPerLine) { currentLineLength = nextLength; continue; } lineCount++; currentLineLength = wordLength; if (lineCount > maxLines) return false; } return lineCount <= maxLines; } private void FillSplitInfoContext(ref DialogueSplitInfo info) { FillSplitInfoContext(ref info, GetDialogueFitContext()); } private void FillSplitInfoContext(ref DialogueSplitInfo info, DialogueFitContext fit) { info.usedPrefabMetrics = fit.usedPrefabMetrics; info.width = fit.width; info.height = fit.height; info.fontSize = fit.fontSize; info.sourceName = fit.sourceName; } private bool TryGetPreferredTextSize( Component textComponent, string text, float width, out Vector2 preferred) { preferred = default; if (textComponent == null) return false; Type type = textComponent.GetType(); MethodInfo method = type.GetMethod( "GetPreferredValues", BindingFlags.Instance | BindingFlags.Public, null, new[] { typeof(string), typeof(float), typeof(float) }, null); if (method == null) return false; try { object value = method.Invoke( textComponent, new object[] { text, width, float.PositiveInfinity }); if (value is Vector2 vector) { preferred = vector; return true; } } catch { } return false; } private Vector4 GetTmpTextMargin(Component textComponent) { if (textComponent == null) return Vector4.zero; Type type = textComponent.GetType(); PropertyInfo property = type.GetProperty( "margin", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (property != null && property.PropertyType == typeof(Vector4)) { try { return (Vector4)property.GetValue(textComponent); } catch { } } FieldInfo field = type.GetField( "m_margin", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field != null && field.FieldType == typeof(Vector4)) { try { return (Vector4)field.GetValue(textComponent); } catch { } } return Vector4.zero; } private float GetTmpTextFontSize(Component textComponent, float fallback) { if (textComponent == null) return fallback; Type type = textComponent.GetType(); PropertyInfo property = type.GetProperty( "fontSize", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (property != null && property.PropertyType == typeof(float)) { try { return (float)property.GetValue(textComponent); } catch { } } FieldInfo field = type.GetField( "m_fontSize", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field != null && field.FieldType == typeof(float)) { try { return (float)field.GetValue(textComponent); } catch { } } return fallback; } private DialogueFitContext GetDialogueFitContext() { GameObject prefab = ResolveDialoguePrefab(); if (m_dialogueFitCacheValid && m_cachedDialoguePrefab == prefab) return m_cachedDialogueFitContext; DialogueFitContext fit = CreateFallbackDialogueFitContext(); if (TryFindDialogueText(prefab, out Component text) && text != null) { RectTransform textRect = text.transform as RectTransform; RectTransform parentRect = text.transform.parent as RectTransform; float width = textRect != null ? textRect.rect.width : 0f; float height = textRect != null ? textRect.rect.height : 0f; if (width <= 1f && parentRect != null) width = parentRect.rect.width; if (height <= 1f && parentRect != null) height = parentRect.rect.height; Vector4 margin = GetTmpTextMargin(text); width = Mathf.Max(80f, width - margin.x - margin.z); height = Mathf.Max(40f, height - margin.y - margin.w); width *= 0.99f; height *= 0.97f; fit.text = text; fit.width = width; fit.height = height; fit.fontSize = Mathf.Max(8f, GetTmpTextFontSize(text, FallbackDialogueFontSize)); fit.usedPrefabMetrics = true; fit.sourceName = prefab != null ? prefab.name : "Dialogue Prefab"; } m_cachedDialoguePrefab = prefab; m_cachedDialogueFitContext = fit; m_dialogueFitCacheValid = true; return fit; } private DialogueFitContext CreateFallbackDialogueFitContext() { return new DialogueFitContext { text = null, width = FallbackDialogueTextWidth, height = FallbackDialogueTextHeight, fontSize = FallbackDialogueFontSize, usedPrefabMetrics = false, sourceName = "fallback" }; } private GameObject ResolveDialoguePrefab() { if (m_dialoguePrefab != null) return m_dialoguePrefab; m_dialoguePrefab = AssetDatabase.LoadAssetAtPath(DefaultDialoguePrefabPath); return m_dialoguePrefab; } private bool TryFindDialogueText(GameObject prefab, out Component text) { text = null; if (prefab == null) return false; UIDialogueMessageBox messageBox = prefab.GetComponentInChildren(true); if (messageBox != null) { SerializedObject serializedMessageBox = new SerializedObject(messageBox); SerializedProperty textProperty = serializedMessageBox.FindProperty("m_text"); if (textProperty != null) text = textProperty.objectReferenceValue as Component; } if (text != null) return true; Component[] candidates = prefab.GetComponentsInChildren(true); for (int i = 0; i < candidates.Length; i++) { Component candidate = candidates[i]; if (candidate == null) continue; Type candidateType = candidate.GetType(); if (candidateType == null || candidateType.FullName != "TMPro.TextMeshProUGUI") { continue; } string objectName = candidate.gameObject.name; if (objectName.IndexOf("Text", StringComparison.OrdinalIgnoreCase) >= 0 && objectName.IndexOf("Option", StringComparison.OrdinalIgnoreCase) < 0 && objectName.IndexOf("Speaker", StringComparison.OrdinalIgnoreCase) < 0) { text = candidate; return true; } } return false; } private void InvalidateDialogueFitCache() { m_dialogueFitCacheValid = false; m_cachedDialoguePrefab = null; m_cachedDialogueFitContext = default; } private void ConfigureDialogueSequence( DialogueSequence sequence, string[] lines, bool questOffer, DialogueSequence acceptOptionSequence = null, DialogueSequence declineOptionSequence = null) { SetMember(sequence, "lines", lines); SetMember(sequence, "m_lines", lines); if (questOffer) { Array options = CreateQuestOfferOptions( acceptOptionSequence, declineOptionSequence); 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( DialogueSequence acceptOptionSequence, DialogueSequence declineOptionSequence) { Type optionType = typeof(DialogueSequenceOption); Array options = Array.CreateInstance(optionType, 2); object accept = CreateDialogueOption( optionType, m_acceptOptionLabel, acceptOptionSequence, true); object decline = CreateDialogueOption( optionType, m_declineOptionLabel, declineOptionSequence, false); options.SetValue(accept, 0); options.SetValue(decline, 1); return options; } private object CreateDialogueOption( Type optionType, string label, DialogueSequence nextSequence, 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", nextSequence); SetMember(option, "m_sequence", nextSequence); 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 assetsFolder = AssetDatabase.LoadAssetAtPath(DefaultOutputRootPath); if (assetsFolder is DefaultAsset assetsAsset) m_outputRoot = assetsAsset; } private void LoadSavedDialogueSettings() { m_autoSplitDialogue = EditorPrefs.GetBool(AutoSplitDialoguePrefsKey, true); string savedPrefabPath = EditorPrefs.GetString( DialoguePrefabPrefsKey, DefaultDialoguePrefabPath); GameObject savedPrefab = null; if (!string.IsNullOrWhiteSpace(savedPrefabPath)) savedPrefab = AssetDatabase.LoadAssetAtPath(savedPrefabPath); if (savedPrefab == null) savedPrefab = AssetDatabase.LoadAssetAtPath(DefaultDialoguePrefabPath); m_dialoguePrefab = savedPrefab; InvalidateDialogueFitCache(); } 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 void SaveDialogueSettings() { EditorPrefs.SetBool(AutoSplitDialoguePrefsKey, m_autoSplitDialogue); if (m_dialoguePrefab == null) { EditorPrefs.DeleteKey(DialoguePrefabPrefsKey); return; } string path = AssetDatabase.GetAssetPath(m_dialoguePrefab); if (!string.IsNullOrWhiteSpace(path)) EditorPrefs.SetString(DialoguePrefabPrefsKey, 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