503 lines
16 KiB
C#
503 lines
16 KiB
C#
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 StandardDialogueUI’s 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) + "…";
|
||
}
|
||
}
|