408 lines
13 KiB
C#
408 lines
13 KiB
C#
|
|
using System.Collections;
|
|||
|
|
using System.Collections.Generic;
|
|||
|
|
using UnityEngine;
|
|||
|
|
using UnityEngine.Events;
|
|||
|
|
using UnityEngine.UI;
|
|||
|
|
using TMPro;
|
|||
|
|
using System.Text;
|
|||
|
|
|
|||
|
|
[System.Serializable]
|
|||
|
|
public class WindowUI
|
|||
|
|
{
|
|||
|
|
public GameObject rootObject;
|
|||
|
|
public TextMeshProUGUI dialogueText;
|
|||
|
|
|
|||
|
|
[Tooltip("Родительский объект портрета (Background). Его мы будем включать/выключать.")]
|
|||
|
|
public GameObject portraitContainer;
|
|||
|
|
|
|||
|
|
[Tooltip("Объект самой иконки (Child), куда будет подставляться спрайт.")]
|
|||
|
|
public Image portraitImage;
|
|||
|
|
|
|||
|
|
[Tooltip("Если кнопки выбора находятся внутри этого окна, укажите их тут (необязательно)")]
|
|||
|
|
public GameObject[] choiceButtons;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public class MANAGER_Dialogues : MonoBehaviour
|
|||
|
|
{
|
|||
|
|
public static MANAGER_Dialogues Instance { get; private set; }
|
|||
|
|
|
|||
|
|
private void Awake()
|
|||
|
|
{
|
|||
|
|
if (Instance == null) Instance = this;
|
|||
|
|
else Destroy(gameObject);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public UnityEvent onSentenceStart;
|
|||
|
|
|
|||
|
|
[Header("Windows Configuration")]
|
|||
|
|
public WindowUI topWindow;
|
|||
|
|
public WindowUI bottomWindow;
|
|||
|
|
|
|||
|
|
[Header("UI Focus Settings")]
|
|||
|
|
public Transform uiRoot;
|
|||
|
|
public List<GameObject> objectsToIgnore = new List<GameObject>();
|
|||
|
|
private List<GameObject> objectsDisabledByDialogue = new List<GameObject>();
|
|||
|
|
|
|||
|
|
[Header("Global Settings")]
|
|||
|
|
public AudioSource audioSource;
|
|||
|
|
public Camera mainCamera;
|
|||
|
|
[Range(0f, 1f)]
|
|||
|
|
public float screenThreshold = 0.5f;
|
|||
|
|
|
|||
|
|
[Header("Emotional Pauses")]
|
|||
|
|
[Tooltip("Длительность паузы в миллисекундах")]
|
|||
|
|
public int emotionalPauseDurationMs = 500;
|
|||
|
|
|
|||
|
|
[Tooltip("Символы, после которых будет пауза. Многоточие '...' будет считаться за одно срабатывание.")]
|
|||
|
|
public List<string> emotionalPauseSymbols = new List<string> { "?", "!", "...", ",", ":" };
|
|||
|
|
|
|||
|
|
[Header("Shared Choice Buttons")]
|
|||
|
|
public GameObject[] globalChoiceButtons;
|
|||
|
|
|
|||
|
|
private WindowUI activeWindow;
|
|||
|
|
private Queue<Sentence> sentences;
|
|||
|
|
private Queue<UnityEvent> sentenceEvents;
|
|||
|
|
private DATA_Dialogue currentDialogue;
|
|||
|
|
private Sentence currentSentence;
|
|||
|
|
|
|||
|
|
private bool isTyping;
|
|||
|
|
private bool skipTyping;
|
|||
|
|
private bool isWaitingForChoice;
|
|||
|
|
private bool canShowNextSentence = true;
|
|||
|
|
|
|||
|
|
|
|||
|
|
private readonly Dictionary<string, string> _textCache = new Dictionary<string, string>();
|
|||
|
|
|
|||
|
|
void Start()
|
|||
|
|
{
|
|||
|
|
sentences = new Queue<Sentence>();
|
|||
|
|
sentenceEvents = new Queue<UnityEvent>();
|
|||
|
|
|
|||
|
|
topWindow.rootObject.SetActive(false);
|
|||
|
|
bottomWindow.rootObject.SetActive(false);
|
|||
|
|
|
|||
|
|
if (mainCamera == null) mainCamera = Camera.main;
|
|||
|
|
|
|||
|
|
if (!objectsToIgnore.Contains(topWindow.rootObject)) objectsToIgnore.Add(topWindow.rootObject);
|
|||
|
|
if (!objectsToIgnore.Contains(bottomWindow.rootObject)) objectsToIgnore.Add(bottomWindow.rootObject);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void Update()
|
|||
|
|
{
|
|||
|
|
bool inputPressed = Input.GetMouseButtonDown(0) || (Input.touchCount > 0 && Input.GetTouch(0).phase == TouchPhase.Began);
|
|||
|
|
|
|||
|
|
if (inputPressed && currentDialogue != null && !isWaitingForChoice)
|
|||
|
|
{
|
|||
|
|
if (isTyping)
|
|||
|
|
{
|
|||
|
|
if (currentSentence != null && currentSentence.isSkipable)
|
|||
|
|
{
|
|||
|
|
skipTyping = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else if (canShowNextSentence)
|
|||
|
|
{
|
|||
|
|
DisplayNextSentence();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public void StartDialogue(DATA_Dialogue dialogue, Transform npcTransform = null, UnityEvent[] events = null)
|
|||
|
|
{
|
|||
|
|
currentDialogue = dialogue;
|
|||
|
|
sentences.Clear();
|
|||
|
|
sentenceEvents.Clear();
|
|||
|
|
isWaitingForChoice = false;
|
|||
|
|
|
|||
|
|
ToggleBackgroundUI(false);
|
|||
|
|
SelectActiveWindow(npcTransform);
|
|||
|
|
|
|||
|
|
foreach (var sentence in dialogue.sentences) sentences.Enqueue(sentence);
|
|||
|
|
if (events != null) foreach (var ev in events) sentenceEvents.Enqueue(ev);
|
|||
|
|
|
|||
|
|
DisplayNextSentence();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void ToggleBackgroundUI(bool show)
|
|||
|
|
{
|
|||
|
|
if (uiRoot == null) return;
|
|||
|
|
|
|||
|
|
if (!show)
|
|||
|
|
{
|
|||
|
|
objectsDisabledByDialogue.Clear();
|
|||
|
|
foreach (Transform child in uiRoot)
|
|||
|
|
{
|
|||
|
|
if (child.gameObject.activeSelf && !objectsToIgnore.Contains(child.gameObject))
|
|||
|
|
{
|
|||
|
|
child.gameObject.SetActive(false);
|
|||
|
|
objectsDisabledByDialogue.Add(child.gameObject);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
foreach (GameObject obj in objectsDisabledByDialogue)
|
|||
|
|
{
|
|||
|
|
if (obj != null) obj.SetActive(true);
|
|||
|
|
}
|
|||
|
|
objectsDisabledByDialogue.Clear();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void SelectActiveWindow(Transform npcTransform)
|
|||
|
|
{
|
|||
|
|
topWindow.rootObject.SetActive(false);
|
|||
|
|
bottomWindow.rootObject.SetActive(false);
|
|||
|
|
|
|||
|
|
if (npcTransform == null)
|
|||
|
|
{
|
|||
|
|
activeWindow = bottomWindow;
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
Vector3 viewportPos = mainCamera.WorldToViewportPoint(npcTransform.position);
|
|||
|
|
activeWindow = (viewportPos.y < screenThreshold) ? topWindow : bottomWindow;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
activeWindow.rootObject.SetActive(true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public void DisplayNextSentence()
|
|||
|
|
{
|
|||
|
|
if (sentences.Count == 0)
|
|||
|
|
{
|
|||
|
|
if (!isTyping && !isWaitingForChoice) EndDialogue();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
currentSentence = sentences.Dequeue();
|
|||
|
|
|
|||
|
|
if (MANAGER_EventsDialogue.Instance != null && !string.IsNullOrEmpty(currentSentence.eventID))
|
|||
|
|
{
|
|||
|
|
MANAGER_EventsDialogue.Instance.Execute(currentSentence.eventID);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
currentSentence.onSentenceStart?.Invoke();
|
|||
|
|
UnityEvent currentEvent = (sentenceEvents.Count > 0) ? sentenceEvents.Dequeue() : null;
|
|||
|
|
|
|||
|
|
UpdatePortrait(currentSentence);
|
|||
|
|
|
|||
|
|
StopAllCoroutines();
|
|||
|
|
StartCoroutine(TypeSentence(currentSentence, currentEvent));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void UpdatePortrait(Sentence sentence)
|
|||
|
|
{
|
|||
|
|
if (activeWindow.portraitContainer != null && activeWindow.portraitImage != null)
|
|||
|
|
{
|
|||
|
|
if (sentence.characterSprite != null)
|
|||
|
|
{
|
|||
|
|
activeWindow.portraitImage.sprite = sentence.characterSprite;
|
|||
|
|
activeWindow.portraitImage.gameObject.SetActive(true);
|
|||
|
|
activeWindow.portraitContainer.SetActive(true);
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
activeWindow.portraitContainer.SetActive(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
IEnumerator TypeSentence(Sentence sentence, UnityEvent onSentenceEnd)
|
|||
|
|
{
|
|||
|
|
isTyping = true;
|
|||
|
|
skipTyping = false;
|
|||
|
|
canShowNextSentence = false;
|
|||
|
|
ClearChoices();
|
|||
|
|
|
|||
|
|
string processedText = ReplaceShortcodes(sentence.text);
|
|||
|
|
activeWindow.dialogueText.text = processedText;
|
|||
|
|
|
|||
|
|
activeWindow.dialogueText.ForceMeshUpdate();
|
|||
|
|
|
|||
|
|
var textInfo = activeWindow.dialogueText.textInfo;
|
|||
|
|
int totalVisibleCharacters = textInfo.characterCount;
|
|||
|
|
activeWindow.dialogueText.maxVisibleCharacters = 0;
|
|||
|
|
|
|||
|
|
WaitForSeconds textDelay = new WaitForSeconds(sentence.speed);
|
|||
|
|
|
|||
|
|
|
|||
|
|
WaitForSeconds emotionalPauseDelay = new WaitForSeconds(emotionalPauseDurationMs / 1000f);
|
|||
|
|
|
|||
|
|
for (int i = 0; i <= totalVisibleCharacters; i++)
|
|||
|
|
{
|
|||
|
|
if (skipTyping)
|
|||
|
|
{
|
|||
|
|
activeWindow.dialogueText.maxVisibleCharacters = totalVisibleCharacters;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
activeWindow.dialogueText.maxVisibleCharacters = i;
|
|||
|
|
|
|||
|
|
if (i > 0)
|
|||
|
|
{
|
|||
|
|
char currentCharacter = textInfo.characterInfo[i - 1].character;
|
|||
|
|
|
|||
|
|
bool isSpecialChar = char.IsWhiteSpace(currentCharacter) ||
|
|||
|
|
currentCharacter == ',' ||
|
|||
|
|
currentCharacter == ' ';
|
|||
|
|
|
|||
|
|
if (!isSpecialChar)
|
|||
|
|
{
|
|||
|
|
if (sentence.voiceClip != null && audioSource != null)
|
|||
|
|
{
|
|||
|
|
audioSource.pitch = Random.Range(sentence.minPitch, sentence.maxPitch);
|
|||
|
|
audioSource.PlayOneShot(sentence.voiceClip);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
if (i > 0 && i < totalVisibleCharacters && ShouldPause(i, textInfo, totalVisibleCharacters))
|
|||
|
|
{
|
|||
|
|
yield return emotionalPauseDelay;
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
yield return textDelay;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
isTyping = false;
|
|||
|
|
skipTyping = false;
|
|||
|
|
yield return null;
|
|||
|
|
canShowNextSentence = true;
|
|||
|
|
|
|||
|
|
onSentenceEnd?.Invoke();
|
|||
|
|
sentence.onSentenceComplete?.Invoke();
|
|||
|
|
|
|||
|
|
if (sentence.choices != null && sentence.choices.Length > 0)
|
|||
|
|
{
|
|||
|
|
ShowChoices(sentence.choices);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
private bool ShouldPause(int currentIndex, TMP_TextInfo textInfo, int totalChars)
|
|||
|
|
{
|
|||
|
|
if (currentIndex <= 0 || currentIndex >= totalChars) return false;
|
|||
|
|
|
|||
|
|
int maxMatchLength = 0;
|
|||
|
|
|
|||
|
|
foreach (string symbol in emotionalPauseSymbols)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(symbol)) continue;
|
|||
|
|
int len = symbol.Length;
|
|||
|
|
if (currentIndex < len) continue;
|
|||
|
|
|
|||
|
|
bool match = true;
|
|||
|
|
for (int j = 0; j < len; j++)
|
|||
|
|
{
|
|||
|
|
if (textInfo.characterInfo[currentIndex - len + j].character != symbol[j])
|
|||
|
|
{
|
|||
|
|
match = false;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (match && len > maxMatchLength)
|
|||
|
|
{
|
|||
|
|
maxMatchLength = len;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (maxMatchLength > 0)
|
|||
|
|
{
|
|||
|
|
|
|||
|
|
|
|||
|
|
char nextChar = textInfo.characterInfo[currentIndex].character;
|
|||
|
|
if (char.IsPunctuation(nextChar))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void ShowChoices(Choice[] choices)
|
|||
|
|
{
|
|||
|
|
isWaitingForChoice = true;
|
|||
|
|
GameObject[] buttonsToUse = (activeWindow.choiceButtons != null && activeWindow.choiceButtons.Length > 0)
|
|||
|
|
? activeWindow.choiceButtons
|
|||
|
|
: globalChoiceButtons;
|
|||
|
|
|
|||
|
|
for (int i = 0; i < buttonsToUse.Length; i++)
|
|||
|
|
{
|
|||
|
|
if (i < choices.Length)
|
|||
|
|
{
|
|||
|
|
buttonsToUse[i].SetActive(true);
|
|||
|
|
buttonsToUse[i].GetComponentInChildren<TextMeshProUGUI>().text = choices[i].choiceText;
|
|||
|
|
|
|||
|
|
Button btn = buttonsToUse[i].GetComponent<Button>();
|
|||
|
|
btn.onClick.RemoveAllListeners();
|
|||
|
|
Choice currentChoice = choices[i];
|
|||
|
|
btn.onClick.AddListener(() => OnChoiceSelected(currentChoice));
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
buttonsToUse[i].SetActive(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void OnChoiceSelected(Choice choice)
|
|||
|
|
{
|
|||
|
|
if (MANAGER_EventsDialogue.Instance != null) MANAGER_EventsDialogue.Instance.Execute(choice.eventID);
|
|||
|
|
choice.onChoiceSelected?.Invoke();
|
|||
|
|
ClearChoices();
|
|||
|
|
isWaitingForChoice = false;
|
|||
|
|
|
|||
|
|
if (choice.nextDialogue != null) StartDialogue(choice.nextDialogue);
|
|||
|
|
else if (sentences.Count > 0) DisplayNextSentence();
|
|||
|
|
else EndDialogue();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void ClearChoices()
|
|||
|
|
{
|
|||
|
|
foreach (var b in globalChoiceButtons) if(b) b.SetActive(false);
|
|||
|
|
if (activeWindow != null && activeWindow.choiceButtons != null)
|
|||
|
|
{
|
|||
|
|
foreach (var b in activeWindow.choiceButtons) if(b) b.SetActive(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private string ReplaceShortcodes(string rawText)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(rawText)) return string.Empty;
|
|||
|
|
|
|||
|
|
if (_textCache.TryGetValue(rawText, out string cachedResult))
|
|||
|
|
{
|
|||
|
|
return cachedResult;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
StringBuilder sb = new StringBuilder(rawText);
|
|||
|
|
sb.Replace("[y]", "<color=#FFFF00>");
|
|||
|
|
sb.Replace("[b]", "<color=#0000FF>");
|
|||
|
|
sb.Replace("[r]", "<color=#FF0000>");
|
|||
|
|
sb.Replace("[o]", "<color=#FFA500>");
|
|||
|
|
sb.Replace("[/c]", "</color>");
|
|||
|
|
|
|||
|
|
string result = sb.ToString();
|
|||
|
|
_textCache[rawText] = result;
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void EndDialogue()
|
|||
|
|
{
|
|||
|
|
topWindow.rootObject.SetActive(false);
|
|||
|
|
bottomWindow.rootObject.SetActive(false);
|
|||
|
|
ToggleBackgroundUI(true);
|
|||
|
|
currentDialogue = null;
|
|||
|
|
}
|
|||
|
|
}
|