// Copyright (c) Pixel Crushers. All rights reserved. using System.Collections; using System.Collections.Generic; using System.Reflection; using UnityEngine; using UnityEngine.Events; namespace PixelCrushers.DialogueSystem { [AddComponentMenu("")] // Use wrapper. public class StandardUIMenuPanel : UIPanel { #region Serialized Fields [Tooltip("(Optional) Main response menu panel.")] public UnityEngine.UI.Graphic panel; [Tooltip("(Optional) Image to show PC portrait during response menu.")] public UnityEngine.UI.Image pcImage; [Tooltip("(Optional) Text element to show PC name during response menu.")] public UITextField pcName; [Tooltip("Set PC Image to actor portrait's native size. Image's Rect Transform can't use Stretch anchors.")] public bool usePortraitNativeSize = false; [Tooltip("(Optional) Slider for timed menus.")] public UnityEngine.UI.Slider timerSlider; [Tooltip("Assign design-time positioned buttons starting with first or last button.")] public ResponseButtonAlignment buttonAlignment = ResponseButtonAlignment.ToFirst; [Tooltip("Show buttons that aren't assigned to any responses. If using a 'dialogue wheel' for example, you'll want to show unused buttons so the entire wheel structure is visible.")] public bool showUnusedButtons = false; [Tooltip("Design-time positioned response buttons. (Optional if Button Template is assigned.)")] public StandardUIResponseButton[] buttons; [Tooltip("Template from which to instantiate response buttons. (Optional if using Buttons list above.)")] public StandardUIResponseButton buttonTemplate; [Tooltip("If using Button Template, instantiate buttons under this GameObject.")] public UnityEngine.UI.Graphic buttonTemplateHolder; [Tooltip("(Optional) Scrollbar to use if instantiated button holder is in a scroll rect.")] public UnityEngine.UI.Scrollbar buttonTemplateScrollbar; [Tooltip("(Optional) Component that enables or disables scrollbar as necessary for content.")] public UIScrollbarEnabler scrollbarEnabler; [Tooltip("Reset the scroll bar to this value when preparing response menu. To skip resetting the scrollbar, specify a negative value.")] public float buttonTemplateScrollbarResetValue = 1; [Tooltip("Automatically set up explicit joystick/keyboard navigation for instantiated template buttons instead of using Automatic navigation.")] public bool explicitNavigationForTemplateButtons = true; [Tooltip("If explicit navigation is enabled, loop around when navigating past end of menu.")] public bool loopExplicitNavigation = false; public UIAutonumberSettings autonumber = new UIAutonumberSettings(); [Tooltip("If non-zero, prevent input for this duration in seconds when opening menu.")] public float blockInputDuration = 0; [Tooltip("During block input duration, keep selected response button in selected visual state.")] public bool showSelectionWhileInputBlocked = false; [Tooltip("Log a warning if a response button text is blank.")] public bool warnOnEmptyResponseText = false; public UnityEvent onContentChanged = new UnityEvent(); [Tooltip("When focusing panel, set this animator trigger.")] public string focusAnimationTrigger = string.Empty; [Tooltip("When unfocusing panel, set this animator trigger.")] public string unfocusAnimationTrigger = string.Empty; [Tooltip("Wait for panels within this dialogue UI (not external) to close before showing menu.")] public bool waitForClose = false; /// /// Invoked when the subtitle panel gains focus. /// public UnityEvent onFocus = new UnityEvent(); /// /// Invoked when the subtitle panel loses focus. /// public UnityEvent onUnfocus = new UnityEvent(); #endregion #region Public Properties [SerializeField, Tooltip("Panel is currently in focused state.")] private bool m_hasFocus = false; public virtual bool hasFocus { get { return m_hasFocus; } protected set { m_hasFocus = value; } } public override bool waitForShowAnimation { get { return true; } } /// /// The instantiated buttons. These are only valid during a specific response menu, /// and only if you're using templates. Each showing of the response menu clears /// this list and re-populates it with new buttons. /// public List instantiatedButtons { get { return m_instantiatedButtons; } } private List m_instantiatedButtons = new List(); #endregion #region Internal Fields protected List instantiatedButtonPool { get { return m_instantiatedButtonPool; } } private List m_instantiatedButtonPool = new List(); private string m_processedAutonumberFormat = string.Empty; private Coroutine m_scrollbarCoroutine = null; protected const float WaitForCloseTimeoutDuration = 8f; protected StandardUITimer m_timer = null; protected System.Action m_timeoutHandler = null; protected CanvasGroup m_mainCanvasGroup = null; protected static bool s_isInputDisabled = false; private StandardDialogueUI m_dialogueUI = null; protected StandardDialogueUI dialogueUI { get { if (m_dialogueUI == null) m_dialogueUI = GetComponentInParent(); return m_dialogueUI ?? DialogueManager.standardDialogueUI; } } #endregion #region Initialization public virtual void Awake() { Tools.SetGameObjectActive(buttonTemplate, false); } #endregion #region Show & Hide protected override void Update() { if (s_isInputDisabled) { if (eventSystem != null) eventSystem.SetSelectedGameObject(null); } else { base.Update(); } } public override void CheckFocus() { if (s_isInputDisabled) return; base.CheckFocus(); } public virtual void SetPCPortrait(Sprite portraitSprite, string portraitName) { if (pcImage != null) { Tools.SetGameObjectActive(pcImage, portraitSprite != null); pcImage.sprite = portraitSprite; if (usePortraitNativeSize && portraitSprite != null) { pcImage.rectTransform.sizeDelta = portraitSprite.packed ? new Vector2(portraitSprite.rect.width, portraitSprite.rect.height) : new Vector2(portraitSprite.texture.width, portraitSprite.texture.height); } } pcName.text = portraitName; } [System.Obsolete("Use SetPCPortrait(Sprite,string) instead.")] public virtual void SetPCPortrait(Texture2D portraitTexture, string portraitName) { SetPCPortrait(UITools.CreateSprite(portraitTexture), portraitName); } public virtual void ShowResponses(Subtitle subtitle, Response[] responses, Transform target) { if (waitForClose && dialogueUI != null) { if (dialogueUI.AreAnyPanelsClosing()) { DialogueManager.instance.StartCoroutine(ShowAfterPanelsClose(subtitle, responses, target)); return; } } CheckForBlankResponses(responses); ShowResponsesNow(subtitle, responses, target); } private void CheckForBlankResponses(Response[] responses) { if (!DialogueDebug.logWarnings) return; if (responses == null) return; foreach (Response response in responses) { if (string.IsNullOrEmpty(response.formattedText.text)) { Debug.LogWarning($"Dialogue System: Response [{response.destinationEntry.conversationID}:{response.destinationEntry.id}] has no text for a response button."); } } } protected virtual void ShowResponsesNow(Subtitle subtitle, Response[] responses, Transform target) { if (responses == null || responses.Length == 0) { if (DialogueDebug.logWarnings) Debug.LogWarning("Dialogue System: StandardDialogueUI ShowResponses received an empty list of responses.", this); return; } ClearResponseButtons(); SetResponseButtons(responses, target); ActivateUIElements(); Open(); Focus(); RefreshSelectablesList(); if (blockInputDuration > 0) { DisableInput(); if (InputDeviceManager.autoFocus) SetFocus(firstSelected); if (Mathf.Approximately(0, Time.timeScale)) { StartCoroutine(EnableInputAfterDuration(blockInputDuration)); } else { Invoke(nameof(EnableInput), blockInputDuration); } } else { if (InputDeviceManager.autoFocus) SetFocus(firstSelected); if (s_isInputDisabled) EnableInput(); } #if TMP_PRESENT DialogueManager.instance.StartCoroutine(CheckTMProAutoScroll()); #endif } private IEnumerator EnableInputAfterDuration(float duration) { yield return new WaitForSecondsRealtime(duration); EnableInput(); } #if TMP_PRESENT // Handles edge case where TMPro uses autoscroll but entry ends before typing starts. // In this case, this method updates the autoscroll size. protected IEnumerator CheckTMProAutoScroll() { var ui = GetComponentInParent(); if (ui == null || ui.conversationUIElements.defaultNPCSubtitlePanel == null || ui.conversationUIElements.defaultNPCSubtitlePanel.subtitleText == null) yield break; var tmp = ui.conversationUIElements.defaultNPCSubtitlePanel.subtitleText.textMeshProUGUI; if (tmp == null) yield break; var layoutElement = tmp.GetComponent(); if (layoutElement != null) layoutElement.preferredHeight = -1; var uiScrollbarEnabler = GetComponentInParent(); if (uiScrollbarEnabler != null) { yield return null; uiScrollbarEnabler.CheckScrollbarWithResetValue(buttonTemplateScrollbarResetValue); } } #endif protected virtual IEnumerator ShowAfterPanelsClose(Subtitle subtitle, Response[] responses, Transform target) { if (dialogueUI != null) { float safeguardTime = Time.realtimeSinceStartup + WaitForCloseTimeoutDuration; while (dialogueUI.AreAnyPanelsClosing() && Time.realtimeSinceStartup < safeguardTime) { yield return null; } } ShowResponsesNow(subtitle, responses, target); } public virtual void HideResponses() { StopTimer(); Unfocus(); Close(); } public override void Close() { if (isOpen) base.Close(); } public virtual void Focus() { if (hasFocus) return; if (panelState == PanelState.Opening && enabled && gameObject.activeInHierarchy) { StartCoroutine(FocusWhenOpen()); } else { FocusNow(); } } protected IEnumerator FocusWhenOpen() { float timeout = Time.realtimeSinceStartup + 5f; while (panelState != PanelState.Open && Time.realtimeSinceStartup < timeout) { yield return null; } FocusNow(); } protected virtual void FocusNow() { panelState = PanelState.Open; animatorMonitor.SetTrigger(focusAnimationTrigger, null, false); UITools.EnableInteractivity(gameObject); if (hasFocus) return; if (string.IsNullOrEmpty(focusAnimationTrigger)) { OnFocused(); } else { animatorMonitor.SetTrigger(focusAnimationTrigger, OnFocused, true); } onFocus.Invoke(); } private void OnFocused() { hasFocus = true; } public virtual void Unfocus() { if (!hasFocus) return; hasFocus = false; animatorMonitor.SetTrigger(unfocusAnimationTrigger, null, false); onUnfocus.Invoke(); } protected void ActivateUIElements() { SetUIElementsActive(true); } protected void DeactivateUIElements() { SetUIElementsActive(false); } protected virtual void SetUIElementsActive(bool value) { Tools.SetGameObjectActive(panel, value); Tools.SetGameObjectActive(pcImage, value && pcImage != null && pcImage.sprite != null); pcName.SetActive(value); Tools.SetGameObjectActive(timerSlider, false); // Let StartTimer activate if needed. if (value == false) ClearResponseButtons(); } public virtual void HideImmediate() { OnHidden(); } protected virtual void ClearResponseButtons() { DestroyInstantiatedButtons(); if (buttons != null) { for (int i = 0; i < buttons.Length; i++) { if (buttons[i] == null) continue; buttons[i].Reset(); buttons[i].isVisible = showUnusedButtons; buttons[i].gameObject.SetActive(showUnusedButtons); } } } /// /// Sets the response buttons. /// /// Responses. /// Target that will receive OnClick events from the buttons. protected virtual void SetResponseButtons(Response[] responses, Transform target) { firstSelected = null; DestroyInstantiatedButtons(); var hasDisabledButton = false; // Prep autonumber format: if (autonumber.enabled) { m_processedAutonumberFormat = FormattedText.Parse(autonumber.format.Replace("\\t", "\t").Replace("\\n", "\n")).text; } if ((buttons != null) && (responses != null)) { // Add explicitly-positioned buttons: int buttonNumber = 0; for (int i = 0; i < responses.Length; i++) { if (responses[i].formattedText.position != FormattedText.NoAssignedPosition) { int position = responses[i].formattedText.position; if (0 <= position && position < buttons.Length && buttons[position] != null) { SetResponseButton(buttons[position], responses[i], target, buttonNumber++); } else { Debug.LogWarning("Dialogue System: Buttons list doesn't contain a button for position " + position + ".", this); } } } if ((buttonTemplate != null) && (buttonTemplateHolder != null)) { if (scrollbarEnabler != null) CheckScrollbar(); // Instantiate buttons from template: for (int i = 0; i < responses.Length; i++) { if (responses[i].formattedText.position != FormattedText.NoAssignedPosition) continue; GameObject buttonGameObject = InstantiateButton(); if (buttonGameObject == null) { Debug.LogError("Dialogue System: Couldn't instantiate response button template."); } else { instantiatedButtons.Add(buttonGameObject); buttonGameObject.transform.SetParent(buttonTemplateHolder.transform, false); buttonGameObject.transform.SetAsLastSibling(); buttonGameObject.SetActive(true); StandardUIResponseButton responseButton = buttonGameObject.GetComponent(); SetResponseButton(responseButton, responses[i], target, buttonNumber++); if (responseButton != null) { buttonGameObject.name = "Response: " + responseButton.text; if (explicitNavigationForTemplateButtons && !responseButton.isClickable) hasDisabledButton = true; } if (firstSelected == null) firstSelected = buttonGameObject; } } } else { // Auto-position remaining buttons: if (buttonAlignment == ResponseButtonAlignment.ToFirst) { // Align to first, so add in order to front: for (int i = 0; i < Mathf.Min(buttons.Length, responses.Length); i++) { if (responses[i].formattedText.position == FormattedText.NoAssignedPosition) { int position = Mathf.Clamp(GetNextAvailableResponseButtonPosition(0, 1), 0, buttons.Length - 1); SetResponseButton(buttons[position], responses[i], target, buttonNumber++); if (firstSelected == null) firstSelected = buttons[position].gameObject; } } } else { // Align to last, so add in reverse order to back: for (int i = Mathf.Min(buttons.Length, responses.Length) - 1; i >= 0; i--) { if (responses[i].formattedText.position == FormattedText.NoAssignedPosition) { int position = Mathf.Clamp(GetNextAvailableResponseButtonPosition(buttons.Length - 1, -1), 0, buttons.Length - 1); SetResponseButton(buttons[position], responses[i], target, buttonNumber++); firstSelected = buttons[position].gameObject; } } } } } if (explicitNavigationForTemplateButtons) SetupTemplateButtonNavigation(hasDisabledButton); NotifyContentChanged(); } protected virtual void CheckScrollbar() { if (scrollbarEnabler == null) return; if (m_scrollbarCoroutine != null) StopCoroutine(m_scrollbarCoroutine); m_scrollbarCoroutine = dialogueUI.StartCoroutine(CheckScrollbarCoroutine()); } protected IEnumerator CheckScrollbarCoroutine() { var timeout = Time.realtimeSinceStartup + UIAnimatorMonitor.MaxWaitDuration; while (!isOpen && Time.realtimeSinceStartup < timeout) { yield return null; } if (buttonTemplateScrollbarResetValue >= 0) { if (buttonTemplateScrollbar != null) buttonTemplateScrollbar.value = buttonTemplateScrollbarResetValue; if (scrollbarEnabler != null) { scrollbarEnabler.CheckScrollbarWithResetValue(buttonTemplateScrollbarResetValue); } } else if (scrollbarEnabler != null) { scrollbarEnabler.CheckScrollbar(); } } protected virtual void SetResponseButton(StandardUIResponseButton button, Response response, Transform target, int buttonNumber) { if (button != null) { button.response = response; button.gameObject.SetActive(true); button.isVisible = true; button.isClickable = response.enabled; button.target = target; if (response != null) { if (warnOnEmptyResponseText && DialogueDebug.logWarnings && string.IsNullOrEmpty(response.formattedText.text)) { Debug.LogWarning($"Dialogue System: Response entry [{response.destinationEntry.id}] menu text is blank.", button); } button.SetFormattedText(response.formattedText); } // Auto-number: if (autonumber.enabled) { button.text = string.Format(m_processedAutonumberFormat, buttonNumber + 1, button.text); // Add UIButtonKeyTrigger(s) if needed: var numKeyTriggersNeeded = 0; if (autonumber.regularNumberHotkeys) numKeyTriggersNeeded++; if (autonumber.numpadHotkeys) numKeyTriggersNeeded++; var keyTriggers = button.GetComponents(); if (keyTriggers.Length < numKeyTriggersNeeded) { for (int i = keyTriggers.Length; i < numKeyTriggersNeeded; i++) { button.gameObject.AddComponent(); } keyTriggers = button.GetComponents(); } int index = 0; if (autonumber.regularNumberHotkeys) { keyTriggers[index++].key = (KeyCode)((int)KeyCode.Alpha1 + buttonNumber); } if (autonumber.numpadHotkeys) { keyTriggers[index].key = (KeyCode)((int)KeyCode.Keypad1 + buttonNumber); } } } } protected int GetNextAvailableResponseButtonPosition(int start, int direction) { if (buttons != null) { int position = start; while ((0 <= position) && (position < buttons.Length)) { if (buttons[position].isVisible && buttons[position].response != null) { position += direction; } else { return position; } } } return 5; } public virtual void SetupTemplateButtonNavigation(bool hasDisabledButton) { // Assumes buttons are active (since uses GetComponent), so call after activating panel. if (instantiatedButtons == null || instantiatedButtons.Count == 0) return; var buttons = new List(); if (hasDisabledButton) { // If some buttons are disabled, make a list of only the clickable ones: buttons.AddRange(instantiatedButtons.FindAll(x => x.GetComponent().isClickable)); } else { buttons.AddRange(instantiatedButtons); } for (int i = 0; i < buttons.Count; i++) { var button = buttons[i].GetComponent(); if (button == null) continue; var above = (i == 0) ? (loopExplicitNavigation ? buttons[buttons.Count - 1].GetComponent() : null) : buttons[i - 1].GetComponent(); var below = (i == buttons.Count - 1) ? (loopExplicitNavigation ? buttons[0].GetComponent() : null) : buttons[i + 1].GetComponent(); var navigation = new UnityEngine.UI.Navigation(); navigation.mode = UnityEngine.UI.Navigation.Mode.Explicit; navigation.selectOnUp = above; navigation.selectOnLeft = above; navigation.selectOnDown = below; navigation.selectOnRight = below; button.navigation = navigation; } } protected virtual GameObject InstantiateButton() { // Try to pull from pool first: if (m_instantiatedButtonPool.Count > 0) { var button = m_instantiatedButtonPool[0]; m_instantiatedButtonPool.RemoveAt(0); return button; } else { return GameObject.Instantiate(buttonTemplate.gameObject) as GameObject; } } public void DestroyInstantiatedButtons() { // Return buttons to pool: for (int i = 0; i < instantiatedButtons.Count; i++) { instantiatedButtons[i].SetActive(false); } m_instantiatedButtonPool.AddRange(instantiatedButtons); instantiatedButtons.Clear(); NotifyContentChanged(); } /// /// Makes the panel's buttons non-clickable. /// Typically called by the dialogue UI as soon as a button has been /// clicked to make sure the player can't click another one while the /// menu is playing its hide animation. /// public virtual void MakeButtonsNonclickable() { for (int i = 0; i < instantiatedButtons.Count; i++) { var responseButton = (instantiatedButtons[i] != null) ? instantiatedButtons[i].GetComponent() : null; if (responseButton != null) responseButton.isClickable = false; } for (int i = 0; i < buttons.Length; i++) { if (buttons[i] != null) buttons[i].isClickable = false; } } protected void NotifyContentChanged() { onContentChanged.Invoke(); } protected void DisableInput() { SetInput(false); } protected void EnableInput() { SetInput(true); } protected void SetInput(bool value) { s_isInputDisabled = (value == false); if (m_mainCanvasGroup == null) { // Try to get dialogue UI's main panel: var ui = GetComponentInParent(); if (ui != null && ui.conversationUIElements.mainPanel != null) { var mainPanel = ui.conversationUIElements.mainPanel; m_mainCanvasGroup = mainPanel.GetComponent() ?? mainPanel.gameObject.AddComponent(); } else { // Otherwise try the menu's panel: var menuPanel = panel; if (menuPanel == null) menuPanel = buttonTemplateHolder; if (menuPanel != null) { m_mainCanvasGroup = menuPanel.GetComponent() ?? menuPanel.gameObject.AddComponent(); } } } if (m_mainCanvasGroup != null) m_mainCanvasGroup.interactable = value; if (value == false) { // If auto focus, show firstSelected in selected state: if (InputDeviceManager.autoFocus && firstSelected != null) { var button = firstSelected.GetComponent(); MethodInfo methodInfo = typeof(UnityEngine.UI.Button).GetMethod("DoStateTransition", BindingFlags.Instance | BindingFlags.NonPublic); methodInfo.Invoke(button, new object[] { 3, true }); // 3 = SelectionState.Selected } } if (eventSystem != null) { var inputModule = eventSystem.GetComponent(); if (inputModule != null) inputModule.enabled = value; } UIButtonKeyTrigger.monitorInput = value; if (value == true) { RefreshSelectablesList(); CheckFocus(); if (eventSystem != null && eventSystem.currentSelectedGameObject != null) { // Also show in focused/selected state: UIUtility.Select(eventSystem.currentSelectedGameObject.GetComponent()); } } } #endregion #region Timer /// /// Starts the timer. /// /// Timeout duration in seconds. /// Invoke this handler on timeout. public virtual void StartTimer(float timeout, System.Action timeoutHandler) { if (m_timer == null) { if (timerSlider != null) { Tools.SetGameObjectActive(timerSlider, true); m_timer = timerSlider.GetComponent(); if (m_timer == null) m_timer = timerSlider.gameObject.AddComponent(); } else { m_timer = GetComponentInChildren(); if (m_timer == null) m_timer = gameObject.AddComponent(); } } Tools.SetGameObjectActive(m_timer, true); m_timer.StartCountdown(timeout, timeoutHandler); } public virtual void StopTimer() { if (m_timer != null) { m_timer.StopCountdown(); Tools.SetGameObjectActive(m_timer, false); } } #endregion } }