// Copyright (c) Pixel Crushers. All rights reserved. using UnityEngine; using UnityEngine.Events; using System.Collections; using System; using System.Text.RegularExpressions; namespace PixelCrushers.DialogueSystem { [AddComponentMenu("")] // Use wrapper. public class StandardUISubtitlePanel : UIPanel { #region Serialized Fields [Tooltip("(Optional) Main panel for subtitle.")] public RectTransform panel; [Tooltip("(Optional) Image for actor's portrait.")] public UnityEngine.UI.Image portraitImage; [Tooltip("(Optional) Text element for actor's name.")] public UITextField portraitName; [Tooltip("Subtitle text.")] public UITextField subtitleText; [Tooltip("Add speaker's name to subtitle text.")] public bool addSpeakerName = false; [Tooltip("Format to add speaker name, where {0} is name and {1} is subtitle text.")] public string addSpeakerNameFormat = "{0}: {1}"; [Tooltip("Each subtitle adds to Subtitle Text instead of replacing it.")] public bool accumulateText = false; [Tooltip("If Accumulate Text is ticked, accumulate up to this many lines, removing the oldest lines when over the limit.")] public int maxLines = 100; [Tooltip("If panel has a typewriter effect, don't start typing until panel's Show animation has completed.")] public bool delayTypewriterUntilOpen = false; [Tooltip("(Optional) Continue button. Only shown if Dialogue Manager's Continue Button mode uses continue button.")] public UnityEngine.UI.Button continueButton; [Tooltip("If non-zero, prevent continue button clicks for this duration in seconds when opening subtitle panel.")] public float blockInputDuration = 0; [Tooltip("When the subtitle UI elements should be visible.")] public UIVisibility visibility = UIVisibility.OnlyDuringContent; [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("If a player actor uses this panel, don't show player portrait name & image; keep previous NPC portrait visible instead.")] public bool onlyShowNPCPortraits = false; [Tooltip("Check Dialogue Actors for portrait animator controllers. Portrait image must have an Animator.")] public bool useAnimatedPortraits = false; [Tooltip("Set Portrait Image to actor portrait's native size. Image's Rect Transform can't use Stretch anchors.")] public bool usePortraitNativeSize = false; [Tooltip("Wait for panel state to be Open before showing subtitle.")] public bool waitForOpen = false; [Tooltip("Wait for panels within this dialogue UI (not external panels) to close before showing.")] public bool waitForClose = false; [Tooltip("Clear text when closing panel, including when hiding using SetDialoguePanel().")] public bool clearTextOnClose = true; [Tooltip("Clear text when any conversation starts.")] public bool clearTextOnConversationStart = false; [Tooltip("If Subtitle Text doesn't have a typewriter effect, to enable scroll to bottom add UIScrollbarEnabler to Scroll Rect and assign it here.")] public UIScrollbarEnabler scrollbarEnabler; /// /// 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 = true; public bool hasFocus { get { return m_hasFocus; } set { m_hasFocus = value; } } [SerializeField, Tooltip("Panel is playing the focus animation.")] private bool m_isFocusing = true; public bool isFocusing { get { return m_isFocusing; } set { m_isFocusing = value; } } public override bool waitForShowAnimation { get { return true; } } private Subtitle m_currentSubtitle = null; public virtual Subtitle currentSubtitle { get { return m_currentSubtitle; } protected set { m_currentSubtitle = value; } } /// /// The database name of the actor whose display name appears in the Portrait Name field. /// public string portraitActorName { get; protected set; } #endregion #region Internal Properties private bool m_haveSavedOriginalColor = false; protected bool haveSavedOriginalColor { get { return m_haveSavedOriginalColor; } set { m_haveSavedOriginalColor = value; } } protected Color originalColor { get; set; } private string m_accumulatedText = string.Empty; public string accumulatedText { get { return m_accumulatedText; } set { m_accumulatedText = value; } } protected int numAccumulatedLines = 0; private Animator m_portraitAnimator = null; protected virtual Animator animator { get { if (m_portraitAnimator == null && portraitImage != null) m_portraitAnimator = portraitImage.GetComponent(); return m_portraitAnimator; } set { m_portraitAnimator = value; } } private Animator m_panelAnimator = null; private bool m_isDefaultNPCPanel = false; public bool isDefaultNPCPanel { get { return m_isDefaultNPCPanel; } set { m_isDefaultNPCPanel = value; } } private bool m_isDefaultPCPanel = false; public bool isDefaultPCPanel { get { return m_isDefaultPCPanel; } set { m_isDefaultPCPanel = value; } } private int m_panelNumber = -1; public int panelNumber { get { return m_panelNumber; } set { m_panelNumber = value; } } public Transform m_actorOverridingPanel = null; public Transform actorOverridingPanel { get { return m_actorOverridingPanel; } set { m_actorOverridingPanel = value; } } private int m_lastActorID = -1; protected int lastActorID { get { return m_lastActorID; } set { m_lastActorID = value; } } protected int frameLastSetContent = -1; // Frame when we last set this panel's content. protected bool shouldShowContinueButton = false; protected const float WaitForCloseTimeoutDuration = 8f; private StandardDialogueUI m_dialogueUI = null; public StandardDialogueUI dialogueUI { get { if (m_dialogueUI == null) { m_dialogueUI = GetComponentInParent(); if (m_dialogueUI == null) m_dialogueUI = DialogueManager.dialogueUI as StandardDialogueUI; } return m_dialogueUI; } set { m_dialogueUI = value; } } protected Coroutine m_focusWhenOpenCoroutine = null; protected Coroutine m_showAfterClosingCoroutine = null; protected Coroutine m_setAnimatorCoroutine = null; protected WaitForEndOfFrame endOfFrame = new WaitForEndOfFrame(); #endregion #region Initialization protected virtual void Awake() { if (addSpeakerName) { addSpeakerNameFormat = addSpeakerNameFormat.Replace("\\n", "\n").Replace("\\t", "\t"); } m_panelAnimator = GetComponent(); } #endregion #region Typewriter Control /// /// Returns the typewriter effect on the subtitle text element, or null if there is none. /// public AbstractTypewriterEffect GetTypewriter() { return TypewriterUtility.GetTypewriter(subtitleText); } /// /// Checks if the subtitle text element has a typewriter effect. /// public bool HasTypewriter() { return GetTypewriter() != null; } /// /// Returns the speed of the typewriter effect on the subtitle element if it has one. /// public float GetTypewriterSpeed() { return TypewriterUtility.GetTypewriterSpeed(subtitleText); } /// /// Sets the speed of the typewriter effect on the subtitle element if it has one. /// public void SetTypewriterSpeed(float charactersPerSecond) { TypewriterUtility.SetTypewriterSpeed(subtitleText, charactersPerSecond); } #endregion #region Show & Hide /// /// Shows the panel at the start of the conversation; called if it's configured to be visible at the start. /// /// The image of the first actor who will use this panel. /// The name of the first actor who will use this panel. /// The actor's DialogueActor component, or null if none. public virtual void OpenOnStartConversation(Sprite portraitSprite, string portraitName, DialogueActor dialogueActor) { Open(); SetUIElementsActive(true); SetPortraitImage(portraitSprite); portraitActorName = (dialogueActor != null) ? dialogueActor.GetActorName() : portraitName; if (this.portraitName != null) this.portraitName.text = portraitActorName; if (subtitleText.text != null) subtitleText.text = string.Empty; CheckDialogueActorAnimator(dialogueActor); } [System.Obsolete("Use OpenOnStartConversation(Sprite,string,DialogueActor) instead.")] public virtual void OpenOnStartConversation(Texture2D portraitTexture, string portraitName, DialogueActor dialogueActor) { OpenOnStartConversation(UITools.CreateSprite(portraitTexture), portraitName, dialogueActor); } public virtual void OnConversationStart(Transform actor) { if (clearTextOnConversationStart && (frameLastSetContent < (Time.frameCount - 1))) // If we just set content, don't clear the text. { ClearText(); } } /// /// Shows a subtitle, playing the open and focus animations. /// public virtual void ShowSubtitle(Subtitle subtitle) { var supercedeOnActorChange = waitForClose && isOpen && visibility == UIVisibility.UntilSupercededOrActorChange && subtitle != null && lastActorID != subtitle.speakerInfo.id; if ((waitForClose && dialogueUI.AreAnyPanelsClosing(this)) || supercedeOnActorChange) { if (supercedeOnActorChange) Close(); StopShowAfterClosingCoroutine(); m_showAfterClosingCoroutine = DialogueManager.instance.StartCoroutine(ShowSubtitleAfterClosing(subtitle)); } else { ShowSubtitleNow(subtitle); } } protected virtual void ShowSubtitleNow(Subtitle subtitle) { SetUIElementsActive(true); if (!isOpen) { hasFocus = false; isFocusing = false; } Open(); Focus(); SetContent(subtitle); actorOverridingPanel = null; } protected virtual IEnumerator ShowSubtitleAfterClosing(Subtitle subtitle) { shouldShowContinueButton = false; float safeguardTime = Time.realtimeSinceStartup + WaitForCloseTimeoutDuration; while (dialogueUI.AreAnyPanelsClosing() && Time.realtimeSinceStartup < safeguardTime) { yield return null; } ShowSubtitleNow(subtitle); if (shouldShowContinueButton) ShowContinueButton(); m_showAfterClosingCoroutine = null; } protected virtual void StopShowAfterClosingCoroutine() { if (m_showAfterClosingCoroutine != null) { DialogueManager.instance.StopCoroutine(m_showAfterClosingCoroutine); m_showAfterClosingCoroutine = null; } } protected virtual void StopFocusWhenOpenCoroutine() { if (m_focusWhenOpenCoroutine != null) { StopCoroutine(m_focusWhenOpenCoroutine); m_focusWhenOpenCoroutine = null; } } public virtual void StopShowSubtitleCoroutines() { StopShowAfterClosingCoroutine(); StopFocusWhenOpenCoroutine(); } /// /// Hides a subtitle, playing the unfocus and close animations. /// public virtual void HideSubtitle(Subtitle subtitle) { if (panelState != PanelState.Closed) Unfocus(); Close(); } /// /// Immediately hides the panel without playing any animations. /// public virtual void HideImmediate() { OnHidden(); } protected override void OnHidden() { base.OnHidden(); if (clearTextOnClose) ClearText(); if (deactivateOnHidden) DeactivateUIElements(); currentSubtitle = null; } /// /// Opens the panel. /// public override void Open() { base.Open(); } /// /// Closes the panel. /// public override void Close() { StopShowAfterClosingCoroutine(); if (isOpen) base.Close(); if (clearTextOnClose && !waitForClose) ClearText(); hasFocus = false; isFocusing = false; } /// /// Focuses the panel. /// public virtual void Focus() { if (panelState == PanelState.Opening && enabled && gameObject.activeInHierarchy) { StopFocusWhenOpenCoroutine(); m_focusWhenOpenCoroutine = StartCoroutine(FocusWhenOpen()); } else { FocusNow(); } } protected IEnumerator FocusWhenOpen() { float timeout = Time.realtimeSinceStartup + 5f; while (panelState != PanelState.Open && Time.realtimeSinceStartup < timeout) { yield return null; } m_focusWhenOpenCoroutine = null; FocusNow(); } protected virtual void FocusNow() { panelState = PanelState.Open; if (hasFocus) return; isFocusing = true; if (m_panelAnimator != null && !string.IsNullOrEmpty(unfocusAnimationTrigger)) m_panelAnimator.ResetTrigger(unfocusAnimationTrigger); if (string.IsNullOrEmpty(focusAnimationTrigger)) { OnFocused(); } else { animatorMonitor.SetTrigger(focusAnimationTrigger, OnFocused, true); } onFocus.Invoke(); } private void OnFocused() { hasFocus = true; isFocusing = false; } /// /// Unfocuses the panel. /// public virtual void Unfocus() { if (m_panelAnimator != null && !string.IsNullOrEmpty(focusAnimationTrigger)) m_panelAnimator.ResetTrigger(focusAnimationTrigger); StopShowSubtitleCoroutines(); if (!string.IsNullOrEmpty(focusAnimationTrigger) && animatorMonitor.currentTrigger == focusAnimationTrigger) { animatorMonitor.CancelCurrentAnimation(); } else { if (!(hasFocus || isFocusing)) { hasFocus = false; isFocusing = false; return; } } if (panelState == PanelState.Opening) panelState = PanelState.Open; hasFocus = false; animatorMonitor.SetTrigger(unfocusAnimationTrigger, null, false); onUnfocus.Invoke(); } public virtual void ActivateUIElements() { SetUIElementsActive(true); } public virtual void DeactivateUIElements() { SetUIElementsActive(false); if (clearTextOnClose) ClearText(); } protected virtual void SetUIElementsActive(bool value) { Tools.SetGameObjectActive(panel, value); Tools.SetGameObjectActive(portraitImage, value && portraitImage != null && portraitImage.sprite != null); portraitName.SetActive(value); subtitleText.SetActive(value); Tools.SetGameObjectActive(continueButton, false); // Let ConversationView determine if continueButton should be shown. } public virtual void ClearText() { m_accumulatedText = string.Empty; subtitleText.text = string.Empty; numAccumulatedLines = 0; } private Coroutine m_ShowContinueButtonCoroutine; public virtual void ShowContinueButton() { if (m_ShowContinueButtonCoroutine != null) { DialogueManager.instance.StopCoroutine(m_ShowContinueButtonCoroutine); } if (blockInputDuration > 0) { m_ShowContinueButtonCoroutine = DialogueManager.instance.StartCoroutine(ShowContinueButtonAfterBlockDuration()); } else { StartCoroutine(ShowContinueButtonAtEndOfFrame()); } } public virtual void HideContinueButton() { if (m_ShowContinueButtonCoroutine != null) { DialogueManager.instance.StopCoroutine(m_ShowContinueButtonCoroutine); } Tools.SetGameObjectActive(continueButton, false); } protected virtual IEnumerator ShowContinueButtonAfterBlockDuration() { if (continueButton == null) yield break; continueButton.interactable = false; // Wait for panel to open, or timeout: var timeout = Time.realtimeSinceStartup + 10f; while (panelState != PanelState.Open && Time.realtimeSinceStartup < timeout) { yield return null; } yield return DialogueManager.instance.StartCoroutine(DialogueTime.WaitForSeconds(blockInputDuration)); continueButton.interactable = true; ShowContinueButtonNow(); m_ShowContinueButtonCoroutine = null; } protected virtual IEnumerator ShowContinueButtonAtEndOfFrame() { // We wait until the end of the frame in case another subtitle panel shares the // same continue button and decides to deactivate it. yield return endOfFrame; ShowContinueButtonNow(); } protected virtual void ShowContinueButtonNow() { Tools.SetGameObjectActive(continueButton, true); if (InputDeviceManager.autoFocus) Select(); if (continueButton != null && continueButton.onClick.GetPersistentEventCount() == 0) { continueButton.onClick.RemoveAllListeners(); var fastForward = continueButton.GetComponent(); if (fastForward != null) { continueButton.onClick.AddListener(fastForward.OnFastForward); } else { continueButton.onClick.AddListener(OnContinue); } } shouldShowContinueButton = true; } /// /// Finishes the subtitle without hiding the panel. Called if the panel is configured to stay open. /// Hides the continue button and stops the typewriter effect. /// public virtual void FinishSubtitle() { HideContinueButton(); var typewriter = GetTypewriter(); if (typewriter != null && typewriter.isPlaying) typewriter.Stop(); } /// /// Selects the panel's continue button (i.e., navigates to it). /// /// Select continue button even if another element is already selected. public virtual void Select(bool allowStealFocus = true) { UITools.Select(continueButton, allowStealFocus, eventSystem); } /// /// The continue button should call this method to continue. /// public virtual void OnContinue() { if (dialogueUI != null) dialogueUI.OnContinueConversation(); } /// /// Sets the content of the panel. Assumes the panel is already open. /// public virtual void SetContent(Subtitle subtitle) { if (subtitle == null) return; currentSubtitle = subtitle; lastActorID = subtitle.speakerInfo.id; CheckSubtitleAnimator(subtitle); if (!onlyShowNPCPortraits || subtitle.speakerInfo.isNPC) { if (portraitImage != null) { var sprite = subtitle.GetSpeakerPortrait(); SetPortraitImage(sprite); } portraitActorName = subtitle.speakerInfo.nameInDatabase; if (portraitName.text != subtitle.speakerInfo.Name) { portraitName.text = subtitle.speakerInfo.Name; UITools.SendTextChangeMessage(portraitName); } } if (waitForOpen && panelState != PanelState.Open) { DialogueManager.instance.StartCoroutine(SetSubtitleTextContentAfterOpen(subtitle)); } else { SetSubtitleTextContent(subtitle); } frameLastSetContent = Time.frameCount; } protected virtual IEnumerator SetSubtitleTextContentAfterOpen(Subtitle subtitle) { float timeout = Time.realtimeSinceStartup + WaitForCloseTimeoutDuration; while (panelState != PanelState.Open && Time.realtimeSinceStartup < timeout) { yield return null; } SetSubtitleTextContent(subtitle); } protected virtual void SetSubtitleTextContent(Subtitle subtitle) { if (addSpeakerName && !string.IsNullOrEmpty(subtitle.speakerInfo.Name)) { subtitle.formattedText.text = FormattedText.Parse(string.Format(addSpeakerNameFormat, new object[] { subtitle.speakerInfo.Name, subtitle.formattedText.text })).text; } TypewriterUtility.StopTyping(subtitleText); var previousText = accumulateText ? m_accumulatedText : string.Empty; if (accumulateText && !string.IsNullOrEmpty(subtitle.formattedText.text)) { if (numAccumulatedLines < maxLines) { numAccumulatedLines += (1 + NumCharOccurrences('\n', subtitle.formattedText.text)); } else { // If we're at the max number of lines, remove the first line from the accumulated text: previousText = RemoveFirstLine(previousText); } } var previousChars = accumulateText ? UITools.StripRPGMakerCodes(Tools.StripTextMeshProTags(Tools.StripRichTextCodes(previousText))).Length : 0; SetFormattedText(subtitleText, previousText, subtitle); if (accumulateText) m_accumulatedText = UITools.StripRPGMakerCodes(subtitleText.text) + "\n"; if (scrollbarEnabler != null && !HasTypewriter()) { scrollbarEnabler.CheckScrollbarWithResetValue(0); } else if (delayTypewriterUntilOpen && !hasFocus) { DialogueManager.instance.StartCoroutine(StartTypingWhenFocused(subtitleText, subtitleText.text, previousChars)); } else { TypewriterUtility.StartTyping(subtitleText, subtitleText.text, previousChars); } } protected virtual string RemoveFirstLine(string previousText) { if (string.IsNullOrEmpty(previousText)) return string.Empty; var newlineIndex = previousText.IndexOf("\n"); if (previousText.Contains("<")) { // Preserve rich text tags in first line: var tags = string.Empty; var firstLine = previousText.Substring(0, newlineIndex); foreach (Match match in Tools.TextMeshProTagsRegex.Matches(firstLine)) { tags += match.Value; } return tags + previousText.Substring(newlineIndex + 1); } else { return previousText.Substring(newlineIndex + 1); } } /// /// Returns the number of times character c occurs in string s. /// protected int NumCharOccurrences(char c, string s) { int count = 0; for (int i = 0; i < s.Length; i++) { if (c == s[i]) count++; } return count; } protected virtual IEnumerator StartTypingWhenFocused(UITextField subtitleText, string text, int fromIndex) { subtitleText.text = string.Empty; float timeout = Time.realtimeSinceStartup + 5f; while ((!hasFocus || panelState != PanelState.Open) && Time.realtimeSinceStartup < timeout) { yield return null; } subtitleText.text = text; TypewriterUtility.StartTyping(subtitleText, text, fromIndex); } protected virtual void SetFormattedText(UITextField textField, string previousText, Subtitle subtitle) { var currentText = UITools.GetUIFormattedText(subtitle.formattedText); textField.text = previousText + currentText; UITools.SendTextChangeMessage(textField); if (!haveSavedOriginalColor) { originalColor = textField.color; haveSavedOriginalColor = true; } textField.color = (subtitle.formattedText.emphases != null && subtitle.formattedText.emphases.Length > 0) ? subtitle.formattedText.emphases[0].color : originalColor; } // No longer used, but kept in case user subclasses use it. protected virtual void SetFormattedText(UITextField textField, string previousText, FormattedText formattedText) { textField.text = previousText + UITools.GetUIFormattedText(formattedText); UITools.SendTextChangeMessage(textField); if (!haveSavedOriginalColor) { originalColor = textField.color; haveSavedOriginalColor = true; } textField.color = (formattedText.emphases != null && formattedText.emphases.Length > 0) ? formattedText.emphases[0].color : originalColor; } public virtual void SetPortraitName(string actorName) { if (portraitName == null) return; portraitName.gameObject.SetActive(!string.IsNullOrEmpty(actorName)); portraitName.text = actorName; } public virtual void SetActorPortraitSprite(string actorName, Sprite portraitSprite) { if (portraitImage == null) return; var sprite = AbstractDialogueUI.GetValidPortraitSprite(actorName, portraitSprite); SetPortraitImage(sprite); } public virtual void SetPortraitImage(Sprite sprite) { if (portraitImage == null) return; Tools.SetGameObjectActive(portraitImage, sprite != null); portraitImage.sprite = sprite; if (usePortraitNativeSize && sprite != null) { portraitImage.rectTransform.sizeDelta = sprite.packed ? new Vector2(sprite.rect.width, sprite.rect.height) : new Vector2(sprite.texture.width, sprite.texture.height); } } public virtual void CheckSubtitleAnimator(Subtitle subtitle) { if (subtitle != null && useAnimatedPortraits && animator != null) { var dialogueActor = DialogueActor.GetDialogueActorComponent(subtitle.speakerInfo.transform); if (dialogueActor != null) // && dialogueActor.standardDialogueUISettings.portraitAnimatorController != null) { var speakerPanelNumber = dialogueActor.GetSubtitlePanelNumber(); var isMyPanel = (actorOverridingPanel == subtitle.speakerInfo.transform) || (PanelNumberUtility.GetSubtitlePanelIndex(speakerPanelNumber) == this.panelNumber) || (speakerPanelNumber == SubtitlePanelNumber.Default && subtitle.speakerInfo.isNPC && isDefaultNPCPanel) || (speakerPanelNumber == SubtitlePanelNumber.Default && subtitle.speakerInfo.isPlayer && isDefaultPCPanel) || (speakerPanelNumber == SubtitlePanelNumber.Custom && dialogueActor.standardDialogueUISettings.customSubtitlePanel == this); if (isMyPanel) { if (m_setAnimatorCoroutine != null) DialogueManager.instance.StopCoroutine(m_setAnimatorCoroutine); m_setAnimatorCoroutine = DialogueManager.instance.StartCoroutine(SetAnimatorAtEndOfFrame(dialogueActor.standardDialogueUISettings.portraitAnimatorController)); } } else { if (m_setAnimatorCoroutine != null) DialogueManager.instance.StopCoroutine(m_setAnimatorCoroutine); m_setAnimatorCoroutine = DialogueManager.instance.StartCoroutine(SetAnimatorAtEndOfFrame(null)); } } } protected virtual void CheckDialogueActorAnimator(DialogueActor dialogueActor) { if (dialogueActor != null && useAnimatedPortraits && animator != null && dialogueActor.standardDialogueUISettings.portraitAnimatorController != null) { if (m_setAnimatorCoroutine != null) DialogueManager.instance.StopCoroutine(m_setAnimatorCoroutine); m_setAnimatorCoroutine = DialogueManager.instance.StartCoroutine(SetAnimatorAtEndOfFrame(dialogueActor.standardDialogueUISettings.portraitAnimatorController)); } } protected virtual IEnumerator SetAnimatorAtEndOfFrame(RuntimeAnimatorController animatorController) { if (animator == null) yield break; if (animator.runtimeAnimatorController != animatorController) { animator.runtimeAnimatorController = animatorController; } if (animatorController != null) { Tools.SetGameObjectActive(portraitImage, portraitImage.sprite != null); } yield return CoroutineUtility.endOfFrame; if (animator.runtimeAnimatorController != animatorController) { animator.runtimeAnimatorController = animatorController; } if (animatorController != null) { Tools.SetGameObjectActive(portraitImage, portraitImage.sprite != null); } animator.enabled = animatorController != null; } #endregion } }