using System; using Cysharp.Threading.Tasks; using TMPro; using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; using Quaternion = UnityEngine.Quaternion; using Vector3 = UnityEngine.Vector3; using TriInspector; namespace R0bbie.VRSubtitles { /// /// Class to handle dialogue panel UI /// [RequireComponent(typeof(AudioSource))] public class SubtitlePanel : MonoBehaviour { // Exposed Variables [Title("UI Child Refs")] [SerializeField] Image characterAvatar; [SerializeField] TMP_Text characterNameText; [SerializeField] TMP_Text subtitleText; //[SerializeField] TextAnimatorPlayer animatorPlayer; [SerializeField] CanvasGroup panelCanvasGroup; [Title("Core Behaviour")] [SerializeField] bool waitForInput; //[SerializeField] bool animateInText; // TODO: Implement [Title("Follow Behaviour")] [SerializeField] float posPanelAtCameraDistance = 0.35f; // how far the subtitle panel will be positioned from the camera [SerializeField] float followCameraMovementSpeed = 2f; [SerializeField] float followCameraRotationSpeed = 2f; [Title("Direction Arrows")] [SerializeField] Image leftArrow; [SerializeField] Image rightArrow; [SerializeField] Image aboveArrow; [SerializeField] Image belowArrow; [Title("Icon Tabs")] [SerializeField] CanvasGroup continueIcons; [Title("Panel Size")] [SerializeField] float smlTextScale = 0.18f; [SerializeField] float medTextScale = 0.25f; [SerializeField] float lrgTextScale = 0.35f; [Title("Audio Clips")] [SerializeField] AudioClip subPopupSound; [SerializeField] AudioClip subContinuedSound; [Title("Fonts")] [SerializeField] TMP_FontAsset defaultFont; [SerializeField] Material defaultFontMat; // Private Variables [System.NonSerialized] string activeLine; CharacterSubtitleData activeCharacterData; Transform vrCameraTransform; Transform speakingCharTransform; //[System.NonSerialized] public bool textAnimatedToEnd; Vector3 childCanvasOffscreenDefaultPos; Vector3 childCanvasOffscreenDefaultRot; public bool panelActive { get; private set; } float timeLastHidden; bool snappedToStartTransform; bool dontFollowCharacter; enum Mode { CameraFollow, FixedToChar } Mode activeMode; enum CharScreenPosition { OnScreen, OffToLeft, OffToRight } CharScreenPosition activeCharScreenPosition; public enum ArrowDirection { Off, Up, Down, Left, Right } ArrowDirection activeArrowDir; public enum SubtitleCharPlacement { Above, Below } SubtitleCharPlacement activeSubsCharPlacement; float followCharacterMinChange; // LeanTween settings float tabFadeInTime = 0.3f; LeanTweenType tabFadeTweenType = LeanTweenType.easeOutSine; int activeTabFadeTweenId; Vector3 targetPosition; Vector3 childTargetPosition; Quaternion targetRotation; AudioSource attachedAudioSource; bool init; // Subscribable events public Action onLineDisplayed; // once line has been fully displayed to the player public Action onSubtitleEnded; // when line is ready to be continued, either automatically or via player input public void Init(Camera _vrCamera) { // Set the player camera position as target position for the parent object vrCameraTransform = _vrCamera.transform; // Get attached audio source (for playing some simple effects on interactions) attachedAudioSource = GetComponent(); timeLastHidden = Time.timeSinceLevelLoad; UpdateArrow(ArrowDirection.Off); // Get canvas group child starting / default transform values, for when UI is positioned when char off screen childCanvasOffscreenDefaultPos = panelCanvasGroup.transform.localPosition; childCanvasOffscreenDefaultRot = panelCanvasGroup.transform.localEulerAngles; // Initialise canvas element to face camera GetComponentInChildren().Init(_vrCamera); // TODO: Initialise text animation here if turned on? // Hide continue icons tab by default ContinueIconsOff(); // Hide panel until first line requested HidePanel(); init = true; } /// /// Display subtitle with requested data /// /// Text to show (dialogue being said) /// Character transform to place the panel arrow /// Speaking character data (name and avatar) to display public void PlaySubtitle(string _text, Transform _charTransform, CharacterSubtitleData _characterDataRef, float _audioDuration, bool _dontAttachSubToCharacter = false) { // Catch not yet initialised if (!init) { Debug.LogError("Tried to show a subtitle without initialising panel first!"); return; } //textAnimatedToEnd = false; // TODO: Ensure font set and size to correct when shown //UpdateFont(); //UpdateSize(); // Hide continue icons tab by default on start of new line ContinueIconsOff(); // Set character transform to tie sub to speakingCharTransform = _charTransform; // Update speaking character name and avatar displayed UpdateCharacterDisplay(_characterDataRef); // Start subtitle position and rotation where we want it, before showing SnapToFollowTarget(); // Set new active line to show activeLine = _text; // TODO: Animate text in (if text scrolling on), otherwise snap in immediately //AnimateText(activeLine); UpdateTextImmediate(_text); // If we want to have this line not follow the character position dontFollowCharacter = _dontAttachSubToCharacter; // Allow continue after audio finished playing Invoke(nameof(LineAudioFinished), _audioDuration); } /// /// Clears current subtitle and hides panel /// public void EndSubtitle() { // If active line if (string.IsNullOrEmpty(activeLine)) { // Manually ended onSubtitleEnded?.Invoke(); } // Clear active line activeLine = null; activeCharacterData = null; // Hide panel HidePanel(); } /// /// Hide the UI element designed to show the "continue" input button etc /// void ContinueIconsOff() { // Stop any existing tab fade tween LeanTween.cancel(activeTabFadeTweenId); // Hide continue icon tab continueIcons.alpha = 0; continueIcons.gameObject.SetActive(false); } /// /// Unity Update function called each frame - we'll make sure subtitle panel positions are up-to-date here /// void Update() { if (!panelActive || !init || !snappedToStartTransform) return; // Get latest target positions for parent and child canvas object CalculateTargetPositions(); // Lerp both parent and child objects pos to target transform.position = Vector3.Lerp(transform.position, targetPosition, Time.fixedDeltaTime * followCameraMovementSpeed); panelCanvasGroup.transform.localPosition = Vector3.Lerp(panelCanvasGroup.transform.localPosition, childTargetPosition, Time.fixedDeltaTime * followCameraMovementSpeed); // If character is off-screen then also lerp rotation to target if (activeCharScreenPosition != CharScreenPosition.OnScreen || dontFollowCharacter) transform.rotation = Quaternion.Lerp(transform.rotation, targetRotation,Time.fixedDeltaTime * followCameraRotationSpeed); // TODO: Only lerp / update positions if they've actually changed // Also add small deadzone at edges } /// /// Get latest target positions for the panel objects, based on character being followed, where they are in relation to the player and camera, etc /// void CalculateTargetPositions() { // Get camera forward vector, projected onto horizontal plane Vector3 cameraForwardProjection = Vector3.ProjectOnPlane(vrCameraTransform.forward, Vector3.up); // Get directional vector from camera to currently speaking character, then project onto horizontal plane Vector3 directionFromCameraToChar = (speakingCharTransform.position - vrCameraTransform.position).normalized; Vector3 cameraToCharProjection = Vector3.ProjectOnPlane(directionFromCameraToChar, Vector3.up); // Calculate angle between two vectors (projected onto a horizontal plane) float angleBetweenVectors = Math.SignedAngleBetween(cameraForwardProjection, cameraToCharProjection, Vector3.up); // Determine if character is on screen if (angleBetweenVectors is >= -45 and <= 45) activeCharScreenPosition = CharScreenPosition.OnScreen; else if (angleBetweenVectors is > 45) activeCharScreenPosition = CharScreenPosition.OffToRight; else if (angleBetweenVectors is < -45) activeCharScreenPosition = CharScreenPosition.OffToLeft; // Update active camera follow mode (if needed based on character position on screen) // unless dontFollowCharacter is set! if (activeCharScreenPosition == CharScreenPosition.OnScreen && !dontFollowCharacter) { if (activeMode != Mode.FixedToChar) UpdateFollowMode(Mode.FixedToChar); } else { if (activeMode != Mode.CameraFollow) UpdateFollowMode(Mode.CameraFollow); } // If character is off-screen then subtitle will follow camera facing direction if (activeCharScreenPosition != CharScreenPosition.OnScreen || dontFollowCharacter) { // Set target position. Will then Lerp parent object to camera position (smooths slightly, with child object at offset in front of camera) targetPosition = vrCameraTransform.position; // Get target rotation, following camera in y axis, but not moving in x or z Vector3 curEulerAngles = transform.eulerAngles; targetRotation = Quaternion.Euler(curEulerAngles.x, vrCameraTransform.eulerAngles.y, curEulerAngles.z); // Reset child pos/rot childTargetPosition = childCanvasOffscreenDefaultPos; panelCanvasGroup.transform.localEulerAngles = childCanvasOffscreenDefaultRot; // Show left/right arrows if (!dontFollowCharacter) { if (activeCharScreenPosition == CharScreenPosition.OffToLeft) UpdateArrow(ArrowDirection.Left); else UpdateArrow(ArrowDirection.Right); } else { UpdateArrow(ArrowDirection.Off); } } else // otherwise we'll position the subtitle UI next to the character, locked in pos { // Get distance from camera to char (directional vector previously calculated above) float distanceFromCameraToChar = Vector3.Distance(vrCameraTransform.position, speakingCharTransform.position); // If distance < a minimum (onscreenCharDistance) if (distanceFromCameraToChar < posPanelAtCameraDistance) { // panel parent at char position targetPosition = speakingCharTransform.position; } else { // panel parent onscreenCharDistance along vector from camera to char targetPosition = vrCameraTransform.position + directionFromCameraToChar * Mathf.Sqrt(posPanelAtCameraDistance); } // Position child object at offset from character pos (above or below) childTargetPosition = Vector3.zero; // Set arrow pointing direction // If no character offset if (activeCharacterData && activeCharacterData.subtitlePositionOffset == 0) { UpdateArrow(ArrowDirection.Up); } else // has offset { childTargetPosition = new Vector3(0, activeCharacterData.subtitlePositionOffset, 0); if (activeCharacterData.subtitlePositionOffset <= 0) UpdateArrow(ArrowDirection.Up); else UpdateArrow(ArrowDirection.Down); } } } /// /// Update current subtitle panel follow mode /// /// Whether subtitle panel is following camera or locked to character in view void UpdateFollowMode(Mode _newMode) { activeMode = _newMode; } /// /// Immediately snap panel position to the target position /// public void SnapToFollowTarget() { CalculateTargetPositions(); // Immediately update both parent and child objects pos to target transform.position = targetPosition; panelCanvasGroup.transform.localPosition = childTargetPosition; // If character is off-screen then also snap rotation to target if (activeCharScreenPosition != CharScreenPosition.OnScreen) transform.rotation = targetRotation; snappedToStartTransform = true; } /// /// Update the arrow to face correct direction (towards speaking character) /// /// void UpdateArrow(ArrowDirection _updateTo) { if (activeArrowDir == _updateTo) return; activeArrowDir = _updateTo; // If we don't want to show an arrow (i.e. if no character is being pointed to, maybe an off-screen narrator etc) if (activeArrowDir == ArrowDirection.Off) { belowArrow.gameObject.SetActive(false); aboveArrow.gameObject.SetActive(false); leftArrow.gameObject.SetActive(false); rightArrow.gameObject.SetActive(false); return; } // Otherwise update active UI element for the desired arrow if (activeArrowDir == ArrowDirection.Down) belowArrow.gameObject.SetActive(true); else belowArrow.gameObject.SetActive(false); if (activeArrowDir == ArrowDirection.Up) aboveArrow.gameObject.SetActive(true); else aboveArrow.gameObject.SetActive(false); if (activeArrowDir == ArrowDirection.Left) leftArrow.gameObject.SetActive(true); else leftArrow.gameObject.SetActive(false); if (activeArrowDir == ArrowDirection.Right) rightArrow.gameObject.SetActive(true); else rightArrow.gameObject.SetActive(false); } /// /// Invoked once the audio associated with this line of dialogue has ended, allowing the subtitle to be continued / ended /// void LineAudioFinished() { // Show continue input tab, if turned on if (waitForInput) { ShowContinueInputsTab(); } else { LineContinued(); } } /// /// Once conditions have been met to end this subtitle line /// void LineContinued() { // Play continued sound if set if (subContinuedSound) attachedAudioSource.PlayOneShot(subContinuedSound); onSubtitleEnded?.Invoke(); activeLine = null; activeCharacterData = null; HidePanel(); } /// /// Activate and show panel /// async UniTaskVoid ShowPanel() { // If panel is being shown, and wasn't already showing // Wait a frame to ensure subtitle UI correctly positioned before showing await UniTask.DelayFrame(2); // Add a slight delay for playing sound again, in case toggled quickly off and on again if (!panelActive && (Time.timeSinceLevelLoad > (timeLastHidden + 0.5f))) { // Play popup sound event if (subPopupSound) attachedAudioSource.PlayOneShot(subPopupSound); } // Show panel panelCanvasGroup.alpha = 1f; panelActive = true; } /// /// Hide the panel and set it as inactive for now /// void HidePanel() { panelActive = false; snappedToStartTransform = false; panelCanvasGroup.alpha = 0f; timeLastHidden = Time.timeSinceLevelLoad; } /// /// Show the tab UI element which displays the "continue" input for subtitle line /// void ShowContinueInputsTab() { // Stop any existing tab fade tween LeanTween.cancel(activeTabFadeTweenId); // TODO: Get correct continue buttons from button mappings / active controller and change sprites accordingly before showing? // Start alpha at 0, then we'll fade in continueIcons.alpha = 0; continueIcons.gameObject.SetActive(true); // Fade in activeTabFadeTweenId = LeanTween.alphaCanvas(continueIcons, 1f, tabFadeInTime).setEase(tabFadeTweenType).uniqueId; // Listen for continue input //if (continueInputEvent != null) //continueInputEvent.RegisterListener(this); } /// /// Update the character avatar and name shown in the dialogue panel /// /// Character whose avatar to use void UpdateCharacterDisplay(CharacterSubtitleData _characterData) { if (!_characterData) return; activeCharacterData = _characterData; // TODO: If no character provided, use a default / anonymous avatar? characterAvatar.sprite = activeCharacterData.avatar; characterNameText.text = activeCharacterData.subtitleDisplayName + ":"; characterNameText.color = activeCharacterData.textNameColour; } /// /// Used internally to update the panel text, and ensure it's displayed /// /// Subtitle text to update to void UpdateTextImmediate(string _textToShow) { // Update text setting subtitleText.text = _textToShow; // Show the dialogue panel ShowPanel(); //textAnimatedToEnd = true; // Line now displayed onLineDisplayed?.Invoke(); } /// /// Updates the font used to display subtitles /// /// TextMeshPro font to use /// Font material to use (if wish to override) public void UpdateFont(TMP_FontAsset _newFont, Material _fontMaterial = null) { characterNameText.font = _newFont; subtitleText.font = _newFont; if (_fontMaterial) { characterNameText.fontMaterial = _fontMaterial; subtitleText.fontMaterial = _fontMaterial; } } public void UpdateSize(int _newScale) { // TODO: Handle scaling better here! switch (_newScale) { case 1: gameObject.transform.localScale = new Vector3(smlTextScale, smlTextScale, smlTextScale); break; case 2: gameObject.transform.localScale = new Vector3(medTextScale, medTextScale, medTextScale); break; case 3: gameObject.transform.localScale = new Vector3(lrgTextScale, lrgTextScale, lrgTextScale); break; } } } }