FantasyAsset/Assets/NPCDialogueAudioSync.cs

503 lines
16 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) + "…";
}
}