FantasyAsset/Assets/NPCDialogueAudioSync.cs

503 lines
16 KiB
C#
Raw Normal View History

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<EntryClip> clips = new List<EntryClip>();
#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<AudioSource>();
if (voiceSource == null)
{
voiceSource = gameObject.AddComponent<AudioSource>();
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 : "<none>")}", 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<DialogueManager>();
if (dm != null)
{
dm.gameObject.SendMessage("OnContinue", SendMessageOptions.DontRequireReceiver);
}
// (B) Standard Dialogue UI instance: call OnContinue directly
var sdui = FindObjectOfType<StandardDialogueUI>();
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<UnityEngine.UI.Button>(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<DialogueManager>();
// 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 StandardDialogueUIs NPC subtitle panel first (most reliable).
var sdui = FindObjectOfType<StandardDialogueUI>();
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<TMP_Text>(npcPanel, "Subtitle Text");
npcTMP = exact != null ? exact : FindSubtitleTMP(npcPanel);
}
#endif
if (npcUGUI == null)
{
var exact = FindByExactName<UnityEngine.UI.Text>(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<T>(Transform root, string exact) where T : Component
{
var comps = root.GetComponentsInChildren<T>(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<TMP_Text>(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<UnityEngine.UI.Text>(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<MonoBehaviour>(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<AudioClip>(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) + "…";
}
}