vr-subtitles/Scripts/SubtitlePanel.cs

619 lines
22 KiB
C#

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
{
/// <summary>
/// Class to handle dialogue panel UI
/// </summary>
[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<AudioSource>();
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<FaceCamera>().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;
}
/// <summary>
/// Display subtitle with requested data
/// </summary>
/// <param name="_text">Text to show (dialogue being said)</param>
/// <param name="_charTransform">Character transform to place the panel arrow</param>
/// <param name="_characterDataRef">Speaking character data (name and avatar) to display</param>
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);
}
/// <summary>
/// Clears current subtitle and hides panel
/// </summary>
public void EndSubtitle()
{
// If active line
if (string.IsNullOrEmpty(activeLine))
{
// Manually ended
onSubtitleEnded?.Invoke();
}
// Clear active line
activeLine = null;
activeCharacterData = null;
// Hide panel
HidePanel();
}
/// <summary>
/// Hide the UI element designed to show the "continue" input button etc
/// </summary>
void ContinueIconsOff()
{
// Stop any existing tab fade tween
LeanTween.cancel(activeTabFadeTweenId);
// Hide continue icon tab
continueIcons.alpha = 0;
continueIcons.gameObject.SetActive(false);
}
/// <summary>
/// Unity Update function called each frame - we'll make sure subtitle panel positions are up-to-date here
/// </summary>
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
}
/// <summary>
/// Get latest target positions for the panel objects, based on character being followed, where they are in relation to the player and camera, etc
/// </summary>
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);
}
}
}
/// <summary>
/// Update current subtitle panel follow mode
/// </summary>
/// <param name="_newMode">Whether subtitle panel is following camera or locked to character in view</param>
void UpdateFollowMode(Mode _newMode)
{
activeMode = _newMode;
}
/// <summary>
/// Immediately snap panel position to the target position
/// </summary>
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;
}
/// <summary>
/// Update the arrow to face correct direction (towards speaking character)
/// </summary>
/// <param name="_updateTo"></param>
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);
}
/// <summary>
/// Invoked once the audio associated with this line of dialogue has ended, allowing the subtitle to be continued / ended
/// </summary>
void LineAudioFinished()
{
// Show continue input tab, if turned on
if (waitForInput)
{
ShowContinueInputsTab();
}
else
{
LineContinued();
}
}
/// <summary>
/// Once conditions have been met to end this subtitle line
/// </summary>
void LineContinued()
{
// Play continued sound if set
if (subContinuedSound)
attachedAudioSource.PlayOneShot(subContinuedSound);
onSubtitleEnded?.Invoke();
activeLine = null;
activeCharacterData = null;
HidePanel();
}
/// <summary>
/// Activate and show panel
/// </summary>
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;
}
/// <summary>
/// Hide the panel and set it as inactive for now
/// </summary>
void HidePanel()
{
panelActive = false;
snappedToStartTransform = false;
panelCanvasGroup.alpha = 0f;
timeLastHidden = Time.timeSinceLevelLoad;
}
/// <summary>
/// Show the tab UI element which displays the "continue" input for subtitle line
/// </summary>
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);
}
/// <summary>
/// Update the character avatar and name shown in the dialogue panel
/// </summary>
/// <param name="_characterData">Character whose avatar to use</param>
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;
}
/// <summary>
/// Used internally to update the panel text, and ensure it's displayed
/// </summary>
/// <param name="_textToShow">Subtitle text to update to</param>
void UpdateTextImmediate(string _textToShow)
{
// Update text setting
subtitleText.text = _textToShow;
// Show the dialogue panel
ShowPanel();
//textAnimatedToEnd = true;
// Line now displayed
onLineDisplayed?.Invoke();
}
/// <summary>
/// Updates the font used to display subtitles
/// </summary>
/// <param name="_newFont">TextMeshPro font to use</param>
/// <param name="_fontMaterial">Font material to use (if wish to override)</param>
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;
}
}
}
}