using System.Collections; using System.Collections.Generic; using System.Text; using System.Text.RegularExpressions; using UnityEngine; using PixelCrushers.DialogueSystem; #if TMP_PRESENT using TMPro; #endif [DisallowMultipleComponent] public class NPCDialogueAudioSync : MonoBehaviour { [Header("Actor Matching (optional)")] public string actorNameInDB = ""; [Header("Audio Output")] public AudioSource voiceSource; [System.Serializable] public struct EntryClip { public int conversationID; public int entryID; public AudioClip clip; } [Header("(Optional) Manual Clips Mapping (wins if present)")] public List clips = new List(); #if TMP_PRESENT private TMP_Text npcTMP; // auto-fetched: NPC subtitle only #endif private UnityEngine.UI.Text npcUGUI; // auto-fetched: NPC subtitle only [Header("Typing & Sync")] [Tooltip("Multiply audio length for typing; 1.05 = finish ~5% after audio.")] public float syncSlack = 1.05f; [Tooltip("If a line has no clip, we still type for this long.")] public float fallbackSecondsNoClip = 1.0f; [Header("Debug")] public bool verboseLogging = true; private Coroutine typingRoutine; private Coroutine continueRoutine; private bool uiSearched = false; private void Awake() { if (voiceSource == null) { voiceSource = GetComponent(); if (voiceSource == null) { voiceSource = gameObject.AddComponent(); voiceSource.playOnAwake = false; } } } // ===================== Dialogue System callbacks ===================== private void OnConversationLine(Subtitle subtitle) { if (subtitle == null || subtitle.speakerInfo == null) return; if (!subtitle.speakerInfo.isNPC) return; if (!string.IsNullOrEmpty(actorNameInDB) && !string.Equals(subtitle.speakerInfo.nameInDatabase, actorNameInDB, System.StringComparison.Ordinal)) { if (verboseLogging) Debug.Log($"[NPCDialogueAudioSync] Ignoring speaker '{subtitle.speakerInfo.nameInDatabase}' (expecting '{actorNameInDB}').", this); return; } EnsureNpcSubtitleLabel(); // find subtitle label (not the name) int convId = subtitle.dialogueEntry != null ? subtitle.dialogueEntry.conversationID : -1; int entryId = subtitle.dialogueEntry != null ? subtitle.dialogueEntry.id : -1; string fullText = GetSubtitleText(subtitle); var clip = LookupClip(subtitle); if (verboseLogging) Debug.Log($"[NPCDialogueAudioSync] Line → Conv:{convId} Entry:{entryId} Text:\"{Trunc(fullText)}\" Clip:{(clip ? clip.name : "")}", this); float duration = (clip && clip.length > 0f) ? clip.length * Mathf.Max(1f, syncSlack) : Mathf.Max(0.01f, fallbackSecondsNoClip); StartTyping(fullText, duration); if (clip) { voiceSource.Stop(); voiceSource.clip = clip; voiceSource.time = 0f; voiceSource.mute = false; voiceSource.volume = 1f; voiceSource.spatialBlend = 0f; // 2D while debugging voiceSource.Play(); if (verboseLogging) Debug.Log($"[NPCDialogueAudioSync] Playing clip '{clip.name}' ({clip.length:F2}s).", this); if (continueRoutine != null) StopCoroutine(continueRoutine); continueRoutine = StartCoroutine(WaitAndContinue(clip.length)); } else { if (verboseLogging) Debug.Log("[NPCDialogueAudioSync] No clip → not auto-continuing.", this); } } private void OnConversationLineEnd(Subtitle subtitle) { // DS may have ended the line early due to settings. Do NOT stop audio. if (typingRoutine != null) { StopCoroutine(typingRoutine); typingRoutine = null; } // If our audio is still playing, schedule a continue for the remaining time: if (voiceSource != null && voiceSource.clip != null && voiceSource.isPlaying) { float remaining = Mathf.Max(0f, voiceSource.clip.length - voiceSource.time); if (continueRoutine != null) StopCoroutine(continueRoutine); continueRoutine = StartCoroutine(WaitAndContinue(remaining)); } // else: if no audio, let DS control it (menu/auto/etc). } // ===================== Typing ===================== private void StartTyping(string fullText, float seconds) { if (HasSubtitleLabel()) { DisableBuiltInTypewritersOn(GetSubtitleTransform()); #if TMP_PRESENT if (npcTMP != null) { npcTMP.text = fullText; typingRoutine = StartCoroutine(TypeTMP(npcTMP, seconds)); return; } #endif if (npcUGUI != null) { typingRoutine = StartCoroutine(TypeUGUI(npcUGUI, fullText, seconds)); return; } } // No label found: do nothing; audio still plays. } #if TMP_PRESENT private IEnumerator TypeTMP(TMP_Text label, float seconds) { label.ForceMeshUpdate(); int total = StripRichTags(label.text).Length; label.maxVisibleCharacters = 0; if (total <= 0 || seconds <= 0.01f) { label.maxVisibleCharacters = int.MaxValue; yield break; } float t = 0f; while (t < seconds) { t += Time.deltaTime; label.maxVisibleCharacters = Mathf.FloorToInt(total * Mathf.Clamp01(t / seconds)); yield return null; } label.maxVisibleCharacters = int.MaxValue; typingRoutine = null; } #endif private IEnumerator TypeUGUI(UnityEngine.UI.Text label, string fullText, float seconds) { string plain = StripRichTags(fullText); int total = plain.Length; if (total <= 0 || seconds <= 0.01f) { label.text = fullText; yield break; } float t = 0f; while (t < seconds) { t += Time.deltaTime; int count = Mathf.FloorToInt(total * Mathf.Clamp01(t / seconds)); label.text = BuildVisibleWithRich(fullText, count); yield return null; } label.text = fullText; typingRoutine = null; } private IEnumerator WaitAndContinue(float seconds) { if (seconds < 0f) seconds = 0f; yield return new WaitForSeconds(seconds+0.5f); AdvanceConversationNow(); continueRoutine = null; } private void AdvanceConversationNow() { // Try everything that can advance a DS conversation, in order. // (A) Dialogue Manager: OnContinue message var dm = FindObjectOfType(); if (dm != null) { dm.gameObject.SendMessage("OnContinue", SendMessageOptions.DontRequireReceiver); } // (B) Standard Dialogue UI instance: call OnContinue directly var sdui = FindObjectOfType(); if (sdui != null) { sdui.SendMessage("OnContinue", SendMessageOptions.DontRequireReceiver); } // (C) Click any visible "continue" button under the active UI if (sdui != null) { var btns = sdui.GetComponentsInChildren(true); foreach (var b in btns) { if (!b || !b.interactable || !b.gameObject.activeInHierarchy) continue; var n = b.gameObject.name.ToLowerInvariant(); if (n.Contains("continue") || n.Contains("next")) { b.onClick.Invoke(); break; } } } // (D) Broadcast as last resort BroadcastMessage("OnContinue", SendMessageOptions.DontRequireReceiver); } //private IEnumerator WaitAndContinue(float seconds) //{ // yield return new WaitForSeconds(seconds); // var dm = FindObjectOfType(); // if (dm != null) dm.gameObject.SendMessage("OnContinue", SendMessageOptions.DontRequireReceiver); // BroadcastMessage("OnContinue", SendMessageOptions.DontRequireReceiver); // continueRoutine = null; //} // ===================== Find the *subtitle* label, not the name ===================== private void EnsureNpcSubtitleLabel() { if (HasSubtitleLabel() && uiSearched) return; // Try using StandardDialogueUI’s NPC subtitle panel first (most reliable). var sdui = FindObjectOfType(); Transform npcPanel = null; if (sdui != null) { // Use reflection to get its npcSubtitle panel object (avoids compile issues across versions). var panelComp = GetFieldOrPropertyComponent(sdui, "npcSubtitle"); if (panelComp != null) npcPanel = panelComp.transform; } // Fallback: well-known path in the prefab. if (npcPanel == null && sdui != null) { npcPanel = sdui.transform.Find("Dialogue Panel/NPC Subtitle Panel"); if (npcPanel == null) npcPanel = sdui.transform; } // Within panel, prefer a component literally named "Subtitle Text". if (npcPanel != null) { #if TMP_PRESENT if (npcTMP == null) { var exact = FindByExactName(npcPanel, "Subtitle Text"); npcTMP = exact != null ? exact : FindSubtitleTMP(npcPanel); } #endif if (npcUGUI == null) { var exact = FindByExactName(npcPanel, "Subtitle Text"); npcUGUI = exact != null ? exact : FindSubtitleUGUI(npcPanel); } } if (verboseLogging) { Debug.Log($"[NPCDialogueAudioSync] Subtitle label → TMP:{(npcTMP ? npcTMP.name : "null")} UGUI:{(npcUGUI ? npcUGUI.name : "null")}", this); } uiSearched = true; } private bool HasSubtitleLabel() { #if TMP_PRESENT if (npcTMP != null) return true; #endif return npcUGUI != null; } private Transform GetSubtitleTransform() { #if TMP_PRESENT if (npcTMP != null) return npcTMP.transform; #endif return npcUGUI != null ? npcUGUI.transform : null; } // Find a component by exact GameObject name under root private T FindByExactName(Transform root, string exact) where T : Component { var comps = root.GetComponentsInChildren(true); foreach (var c in comps) if (c.gameObject.name == exact) return c; return null; } #if TMP_PRESENT // Heuristic: find TMP subtitle (NOT name). Prefer objects with "Subtitle" in name; reject those with "Name" private TMP_Text FindSubtitleTMP(Transform root) { TMP_Text fallback = null; foreach (var t in root.GetComponentsInChildren(true)) { var n = t.gameObject.name.ToLowerInvariant(); if (n.Contains("name")) continue; // reject name labels if (n.Contains("subtitle")) return t; // perfect match fallback = t; // keep anything else as last resort } return fallback; } #endif private UnityEngine.UI.Text FindSubtitleUGUI(Transform root) { UnityEngine.UI.Text fallback = null; foreach (var t in root.GetComponentsInChildren(true)) { var n = t.gameObject.name.ToLowerInvariant(); if (n.Contains("name")) continue; // reject name labels if (n.Contains("subtitle")) return t; // perfect match fallback = t; } return fallback; } private Component GetFieldOrPropertyComponent(object obj, string member) { if (obj == null) return null; var type = obj.GetType(); var pi = type.GetProperty(member); if (pi != null) return pi.GetValue(obj, null) as Component; var fi = type.GetField(member); if (fi != null) return fi.GetValue(obj) as Component; return null; } private void DisableBuiltInTypewritersOn(Transform root) { if (root == null) return; foreach (var mb in root.GetComponentsInChildren(true)) { if (mb == null) continue; var n = mb.GetType().Name; if (n.IndexOf("Typewriter", System.StringComparison.OrdinalIgnoreCase) >= 0) mb.enabled = false; } } // ===================== Clip lookup ===================== private AudioClip LookupClip(Subtitle subtitle) { int convId = subtitle.dialogueEntry != null ? subtitle.dialogueEntry.conversationID : -1; int entryId = subtitle.dialogueEntry != null ? subtitle.dialogueEntry.id : -1; // 1) explicit mapping for (int i = 0; i < clips.Count; i++) if (clips[i].conversationID == convId && clips[i].entryID == entryId) return clips[i].clip; // 2) pull directly from THIS entry's fields var entry = subtitle.dialogueEntry; if (entry != null && entry.fields != null) { foreach (var f in entry.fields) { var obj = TryGetUnityObjectFromField(f); if (obj is AudioClip ac) return ac; if (!string.IsNullOrEmpty(f.title) && f.title.Equals("Audio Files", System.StringComparison.OrdinalIgnoreCase)) { var path = f.value; if (!string.IsNullOrEmpty(path)) { var loaded = Resources.Load(path); if (loaded != null) return loaded; } } } } return null; } private UnityEngine.Object TryGetUnityObjectFromField(Field f) { if (f == null) return null; var type = f.GetType(); var pi = type.GetProperty("asset"); if (pi != null) { var v = pi.GetValue(f, null) as UnityEngine.Object; if (v != null) return v; } pi = type.GetProperty("unityObject"); if (pi != null) { var v = pi.GetValue(f, null) as UnityEngine.Object; if (v != null) return v; } var fi = type.GetField("asset"); if (fi != null) { var v = fi.GetValue(f) as UnityEngine.Object; if (v != null) return v; } fi = type.GetField("unityObject"); if (fi != null) { var v = fi.GetValue(f) as UnityEngine.Object; if (v != null) return v; } return null; } // ===================== Text helpers ===================== private static readonly Regex richTag = new Regex("<.*?>", RegexOptions.Singleline); private string GetSubtitleText(Subtitle s) { if (s.formattedText != null && !string.IsNullOrEmpty(s.formattedText.text)) return s.formattedText.text; if (s.dialogueEntry != null) return s.dialogueEntry.DialogueText ?? string.Empty; return string.Empty; } private string StripRichTags(string s) => string.IsNullOrEmpty(s) ? "" : richTag.Replace(s, ""); private string BuildVisibleWithRich(string richText, int visibleCount) { if (string.IsNullOrEmpty(richText)) return ""; if (visibleCount <= 0) return ""; string plain = StripRichTags(richText); visibleCount = Mathf.Clamp(visibleCount, 0, plain.Length); int visibleSoFar = 0; var sb = new StringBuilder(richText.Length); bool inTag = false; foreach (char c in richText) { if (c == '<') { inTag = true; sb.Append(c); continue; } if (c == '>') { inTag = false; sb.Append(c); continue; } if (inTag) { sb.Append(c); continue; } if (visibleSoFar < visibleCount) { sb.Append(c); visibleSoFar++; } } return sb.ToString(); } private string Trunc(string s, int max = 80) { if (string.IsNullOrEmpty(s)) return ""; return s.Length <= max ? s : s.Substring(0, max) + "…"; } }