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