diff --git a/BeatSaberMarkupLanguage/BeatSaberMarkupLanguage.csproj b/BeatSaberMarkupLanguage/BeatSaberMarkupLanguage.csproj index 40ed50f6..1baed152 100644 --- a/BeatSaberMarkupLanguage/BeatSaberMarkupLanguage.csproj +++ b/BeatSaberMarkupLanguage/BeatSaberMarkupLanguage.csproj @@ -149,6 +149,7 @@ + @@ -218,6 +219,7 @@ + @@ -265,6 +267,7 @@ + diff --git a/BeatSaberMarkupLanguage/Components/ScrollingText.cs b/BeatSaberMarkupLanguage/Components/ScrollingText.cs new file mode 100644 index 00000000..0c373de5 --- /dev/null +++ b/BeatSaberMarkupLanguage/Components/ScrollingText.cs @@ -0,0 +1,413 @@ +using System.Collections; +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +namespace BeatSaberMarkupLanguage.Components +{ + [RequireComponent(typeof(RectTransform), typeof(RectMask2D))] + public class ScrollingText : MonoBehaviour + { + public RectTransform rectTransform { get; private set; } + public TextMeshProUGUI textComponent { get; private set; } + + private string _cachedText = ""; + + public ScrollMovementType movementType { get; set; } = ScrollMovementType.ByDuration; + private ScrollAnimationType _animationType = ScrollAnimationType.Basic; + public ScrollAnimationType animationType + { + get => _animationType; + set + { + if (_animationType == value) + return; + + _animationType = value; + + if (_scrollAnimationCoroutine != null) + { + StopCoroutine(_scrollAnimationCoroutine); + StartAnimation(); + } + } + } + private float _textWidthRatioThreshold = 1.2f; + /// + /// The minimum ratio of text width to container width before scrolling occurs. + /// Otherwise, if the text is wider than the container, the text will be scaled down to fit the container. + /// + public float textWidthRatioThreshold + { + get => _textWidthRatioThreshold; + set + { + if (_textWidthRatioThreshold == value || value < 1f) + return; + + _textWidthRatioThreshold = value; + } + } + private WaitForSeconds _pauseDuration = new WaitForSeconds(2f); + /// + /// The number of seconds to wait before the scrolling animation starts and ends. + /// + public float pauseDuration + { + set + { + if (value > 0f) + _pauseDuration = new WaitForSeconds(value); + } + } + private float _scrollDuration = 2f; + /// + /// The number of seconds it takes to scroll the text. + /// + public float scrollDuration + { + get => _scrollDuration; + set + { + if (value > 0f) + _scrollDuration = value; + } + } + private float _scrollSpeed = 20f; + /// + /// The speed at which the text scrolls in units per second. + /// + public float scrollSpeed + { + get => _scrollSpeed; + set + { + if (value > 0f) + _scrollSpeed = value; + } + } + public bool alwaysScroll { get; set; } = false; + + private float _cachedTextWidth = 0f; + private float TextWidth + { + get + { + if (_cachedText != textComponent.text) + { + _cachedTextWidth = textComponent.GetPreferredValues().x; + _cachedText = textComponent.text; + } + + return _cachedTextWidth; + } + } + private float TextWidthRatio => rectTransform.rect.width != 0f ? TextWidth / rectTransform.rect.width : 0f; + + private IEnumerator _scrollAnimationCoroutine; + + private const float ScalingMinimumSizeRatio = 0.98f; + private const float FadeDurationSeconds = 0.6f; + + private void Awake() + { + rectTransform = GetComponent(); + + textComponent = BeatSaberUI.CreateText(rectTransform, "", Vector2.zero); + var rt = textComponent.rectTransform; + rt.anchorMin = Vector2.zero; + rt.anchorMax = Vector2.one; + rt.pivot = new Vector2(0.5f, 0.5f); + rt.sizeDelta = Vector2.zero; + + // the text object can be hit by the raycast, even when it is masked off + // i'm guessing it has something to do with how the text is now curved + // in any case, disallow the text to be a raycast target so it doesn't cover other UI elements + textComponent.raycastTarget = false; + + // allow animation to restart when text has changed + textComponent.RegisterDirtyLayoutCallback(OnTextComponentDirtyLayout); + } + + private void OnEnable() + { + RecalculateElements(true); + } + + private void OnDisable() + { + if (_scrollAnimationCoroutine != null) + { + StopCoroutine(_scrollAnimationCoroutine); + _scrollAnimationCoroutine = null; + } + } + + private void OnTextComponentDirtyLayout() + { + if (textComponent.text != _cachedText) + StartCoroutine(DelayedRecalculateElements()); + } + + private void OnRectTransformDimensionsChange() + { + RecalculateElements(true); + } + + private void RecalculateElements(bool force = false) + { + // do not reset the animation if the text has not changed + // _cachedText will be set when accessing TextWidth + if (textComponent.text == _cachedText && !force) + return; + + if (_scrollAnimationCoroutine != null) + { + StopCoroutine(_scrollAnimationCoroutine); + _scrollAnimationCoroutine = null; + } + + var rt = textComponent.rectTransform; + rt.anchoredPosition = Vector2.zero; + if (TextWidthRatio >= _textWidthRatioThreshold || alwaysScroll) + { + // resize the text component's RectTransform to take on the width of the text + // otherwise, if the text is too long, it will disappear + rt.anchorMin = new Vector2(0.5f, 0f); + rt.anchorMax = new Vector2(0.5f, 1f); + rt.sizeDelta = new Vector2(TextWidth, 0f); + + // position is set in the start of the animation + StartAnimation(); + } + else + { + // if we're changing the text here, this function will fire once again, + // but that should be fine + if (TextWidthRatio > ScalingMinimumSizeRatio) + { + string scaleString = ((int)(100 * (ScalingMinimumSizeRatio + 0.001f) / TextWidthRatio)).InvariantToString("N0"); + textComponent.text = $"" + textComponent.text + ""; + } + + rt.anchorMin = Vector2.zero; + rt.anchorMax = Vector2.one; + rt.sizeDelta = Vector2.zero; + rt.anchoredPosition = Vector2.zero; + } + } + + private IEnumerator DelayedRecalculateElements(bool force = false) + { + yield return null; + RecalculateElements(force); + } + + private void StartAnimation() + { + if (animationType == ScrollAnimationType.FadeInOut) + _scrollAnimationCoroutine = FadeScrollAnimationCoroutine(); + else if (animationType == ScrollAnimationType.ForwardAndReverse) + _scrollAnimationCoroutine = ForwardReverseScrollAnimationCoroutine(); + else if (animationType == ScrollAnimationType.Continuous) + _scrollAnimationCoroutine = ContinuousScrollAnimationCoroutine(); + else + _scrollAnimationCoroutine = BasicScrollAnimationCoroutine(); + + StartCoroutine(_scrollAnimationCoroutine); + } + + #region Animation Coroutines + private IEnumerator ScrollAnimationCoroutine(Vector2 startPos, Vector2 endPos) + { + RectTransform rt = textComponent.rectTransform; + + if (movementType == ScrollMovementType.ByDuration) + { + Vector2 nextPos = startPos; + float seconds = 0f; + + if (startPos.x > endPos.x) + { + // left to right + while (nextPos.x > endPos.x) + { + nextPos.x = Mathf.Lerp(startPos.x, endPos.x, seconds / scrollDuration); + rt.anchoredPosition = nextPos; + seconds += Time.deltaTime; + yield return null; + } + } + else + { + // right to left + while (nextPos.x < endPos.x) + { + nextPos.x = Mathf.Lerp(startPos.x, endPos.x, seconds / scrollDuration); + rt.anchoredPosition = nextPos; + seconds += Time.deltaTime; + yield return null; + } + } + } + else if (movementType == ScrollMovementType.BySpeed) + { + Vector2 nextPos = startPos; + if (startPos.x > endPos.x) + { + // left to right + nextPos.x -= Time.deltaTime * scrollSpeed; + while (nextPos.x > endPos.x) + { + rt.anchoredPosition = nextPos; + yield return null; + nextPos.x -= Time.deltaTime * scrollSpeed; + } + } + else + { + // right to left + nextPos.x += Time.deltaTime * scrollSpeed; + while (nextPos.x < endPos.x) + { + rt.anchoredPosition = nextPos; + yield return null; + nextPos.x += Time.deltaTime * scrollSpeed; + } + } + } + } + + private IEnumerator BasicScrollAnimationCoroutine() + { + RectTransform rt = this.textComponent.rectTransform; + float halfTextWidth = TextWidth / 2f; + float halfWidth = rectTransform.rect.width / 2f; + Vector2 startPos = new Vector2(halfTextWidth - halfWidth, 0f); + Vector2 endPos = new Vector2(halfWidth - halfTextWidth, 0f); + + while (true) + { + rt.anchoredPosition = startPos; + yield return _pauseDuration; + + IEnumerator anim = ScrollAnimationCoroutine(startPos, endPos); + while (anim.MoveNext()) + yield return anim.Current; + + rt.anchoredPosition = endPos; + yield return _pauseDuration; + } + } + + private IEnumerator FadeScrollAnimationCoroutine() + { + RectTransform rt = this.textComponent.rectTransform; + float halfTextWidth = TextWidth / 2f; + float halfWidth = rectTransform.rect.width / 2f; + Vector2 startPos = new Vector2(halfTextWidth - halfWidth, 0f); + Vector2 endPos = new Vector2(halfWidth - halfTextWidth, 0f); + + while (true) + { + rt.anchoredPosition = startPos; + + // fade in + float seconds = 0f; + while (seconds < FadeDurationSeconds) + { + yield return null; + + Color currentColor = textComponent.color; + currentColor.a = Mathf.Lerp(0f, 1f, seconds / FadeDurationSeconds); + textComponent.color = currentColor; + + seconds += Time.deltaTime; + } + yield return _pauseDuration; + + IEnumerator anim = ScrollAnimationCoroutine(startPos, endPos); + while (anim.MoveNext()) + yield return anim.Current; + + rt.anchoredPosition = endPos; + yield return _pauseDuration; + + // fade out + seconds = 0f; + while (seconds < FadeDurationSeconds) + { + Color currentColor = textComponent.color; + currentColor.a = Mathf.Lerp(1f, 0f, seconds / FadeDurationSeconds); + textComponent.color = currentColor; + + seconds += Time.deltaTime; + yield return null; + } + } + } + + private IEnumerator ForwardReverseScrollAnimationCoroutine() + { + RectTransform rt = this.textComponent.rectTransform; + float halfTextWidth = TextWidth / 2f; + float halfWidth = rectTransform.rect.width / 2f; + Vector2 startPos = new Vector2(halfTextWidth - halfWidth, 0f); + Vector2 endPos = new Vector2(halfWidth - halfTextWidth, 0f); + + while (true) + { + rt.anchoredPosition = startPos; + yield return _pauseDuration; + + IEnumerator anim = ScrollAnimationCoroutine(startPos, endPos); + while (anim.MoveNext()) + yield return anim.Current; + + rt.anchoredPosition = endPos; + yield return _pauseDuration; + + anim = ScrollAnimationCoroutine(endPos, startPos); + while (anim.MoveNext()) + yield return anim.Current; + } + } + + private IEnumerator ContinuousScrollAnimationCoroutine() + { + RectTransform rt = this.textComponent.rectTransform; + float halfTextWidth = TextWidth / 2f; + float halfWidth = rectTransform.rect.width / 2f; + Vector2 startPos = new Vector2(halfTextWidth + halfWidth, 0f); + Vector2 endPos = new Vector2(-halfTextWidth - halfWidth, 0f); + + while (true) + { + rt.anchoredPosition = startPos; + yield return null; + + IEnumerator anim = ScrollAnimationCoroutine(startPos, endPos); + while (anim.MoveNext()) + yield return anim.Current; + + rt.anchoredPosition = endPos; + yield return null; + } + } + #endregion + + public enum ScrollMovementType + { + ByDuration, + BySpeed + } + + public enum ScrollAnimationType + { + Basic, + FadeInOut, + ForwardAndReverse, + Continuous + } + } +} diff --git a/BeatSaberMarkupLanguage/Parse.cs b/BeatSaberMarkupLanguage/Parse.cs index 0fa8ffec..1b6e929e 100644 --- a/BeatSaberMarkupLanguage/Parse.cs +++ b/BeatSaberMarkupLanguage/Parse.cs @@ -53,5 +53,10 @@ public static string InvariantToString(this object obj) else return obj.ToString(); } + + public static string InvariantToString(this IFormattable obj, string format) + { + return obj.ToString(format, CultureInfo.InvariantCulture); + } } } diff --git a/BeatSaberMarkupLanguage/Tags/ScrollingTextTag.cs b/BeatSaberMarkupLanguage/Tags/ScrollingTextTag.cs new file mode 100644 index 00000000..ed0db776 --- /dev/null +++ b/BeatSaberMarkupLanguage/Tags/ScrollingTextTag.cs @@ -0,0 +1,34 @@ +using UnityEngine; +using UnityEngine.UI; +using BeatSaberMarkupLanguage.Components; + +namespace BeatSaberMarkupLanguage.Tags +{ + public class ScrollingTextTag : BSMLTag + { + public override string[] Aliases => new[] { "scrolling-text" }; + + public override GameObject CreateObject(Transform parent) + { + GameObject gameObject = new GameObject("BSMLScrollingText"); + gameObject.transform.SetParent(parent, false); + + ScrollingText scrollingText = gameObject.AddComponent(); + scrollingText.rectTransform.anchorMin = new Vector2(0.5f, 0.5f); + scrollingText.rectTransform.anchorMax = new Vector2(0.5f, 0.5f); + scrollingText.rectTransform.sizeDelta = new Vector2(90f, 6f); + scrollingText.textComponent.text = "Default Text"; + scrollingText.textComponent.fontSize = 3f; + scrollingText.textComponent.color = Color.white; + + LayoutElement layoutElement = gameObject.AddComponent(); + layoutElement.preferredHeight = 6f; + layoutElement.preferredWidth = 90f; + + ExternalComponents externalComponents = gameObject.AddComponent(); + externalComponents.components.Add(scrollingText.textComponent); + + return gameObject; + } + } +} diff --git a/BeatSaberMarkupLanguage/TypeHandlers/ScrollingTextHandler.cs b/BeatSaberMarkupLanguage/TypeHandlers/ScrollingTextHandler.cs new file mode 100644 index 00000000..3083fbe5 --- /dev/null +++ b/BeatSaberMarkupLanguage/TypeHandlers/ScrollingTextHandler.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using BeatSaberMarkupLanguage.Components; + +namespace BeatSaberMarkupLanguage.TypeHandlers +{ + [ComponentHandler(typeof(ScrollingText))] + public class ScrollingTextHandler : TypeHandler + { + public override Dictionary Props => new Dictionary() + { + { "movementType", new[]{ "movement-type" } }, + { "animationType", new[]{ "animation-type" } }, + { "textWidthRatioThreshold", new[]{ "text-width-ratio-threshold" } }, + { "pauseDuration", new[]{ "pause-duration" } }, + { "scrollDuration", new[]{ "scroll-duration" } }, + { "scrollSpeed", new[]{ "scroll-speed" } }, + { "alwaysScroll", new[]{ "always-scroll" } }, + }; + + public override Dictionary> Setters => new Dictionary>() + { + { "movementType", new Action((scrollingText, value) => scrollingText.movementType = (ScrollingText.ScrollMovementType)Enum.Parse(typeof(ScrollingText.ScrollMovementType), value)) }, + { "animationType", new Action((scrollingText, value) => scrollingText.animationType = (ScrollingText.ScrollAnimationType)Enum.Parse(typeof(ScrollingText.ScrollAnimationType), value)) }, + { "textWidthRatioThreshold", new Action((scrollingText, value) => scrollingText.textWidthRatioThreshold = Parse.Float(value)) }, + { "pauseDuration", new Action((scrollingText, value) => scrollingText.pauseDuration = Parse.Float(value)) }, + { "scrollDuration", new Action((scrollingText, value) => scrollingText.scrollDuration = Parse.Float(value)) }, + { "scrollSpeed", new Action((scrollingText, value) => scrollingText.scrollSpeed = Parse.Float(value)) }, + { "alwaysScroll", new Action((scrollingText, value) => scrollingText.alwaysScroll = Parse.Bool(value)) }, + }; + } +} diff --git a/BeatSaberMarkupLanguage/ViewControllers/TestViewController.cs b/BeatSaberMarkupLanguage/ViewControllers/TestViewController.cs index 055c205b..30ad898a 100644 --- a/BeatSaberMarkupLanguage/ViewControllers/TestViewController.cs +++ b/BeatSaberMarkupLanguage/ViewControllers/TestViewController.cs @@ -77,6 +77,62 @@ public List contents2 } } + private string _changeableText = "this is a changeable text example that should serve as a testing ground for trying to see what happens when you change the text while the scrolling animation is playing"; + [UIValue("changeable-text")] + public string ChangeableText + { + get => _changeableText; + set + { + _changeableText = value; + NotifyPropertyChanged(); + } + } + private bool _alwaysScroll = false; + [UIValue("always-scroll")] + public bool AlwaysScroll + { + get => _alwaysScroll; + set + { + _alwaysScroll = value; + NotifyPropertyChanged(); + } + } + private float _pauseDuration = 4f; + [UIValue("pause-duration")] + public float PauseDuration + { + get => _pauseDuration; + set + { + _pauseDuration = value; + NotifyPropertyChanged(); + } + } + private ScrollingText.ScrollAnimationType _animationType = ScrollingText.ScrollAnimationType.ForwardAndReverse; + [UIValue("animation-type")] + public ScrollingText.ScrollAnimationType AnimationType + { + get => _animationType; + set + { + _animationType = value; + NotifyPropertyChanged(); + } + } + private float _scrollSpeed = 15f; + [UIValue("scroll-speed")] + public float ScrollSpeed + { + get => _scrollSpeed; + set + { + _scrollSpeed = value; + NotifyPropertyChanged(); + } + } + [UIAction("click")] private void ButtonPress() @@ -108,6 +164,54 @@ private void PostParse() tableData.data = test; tableData.tableView.ReloadData(); } + + [UIAction("text-1-button-clicked")] + private void ScrollingTextText1ButtonClicked() + { + AlwaysScroll = false; + ChangeableText = "short text example, stop animation"; + } + + [UIAction("text-2-button-clicked")] + private void ScrollingTextText2ButtonClicked() + { + AlwaysScroll = false; + ChangeableText = "this is a long text example that should cause the scrolling animation to play again. if it doesn't start the scrolling animation again, then something is wrong with the code"; + } + + [UIAction("text-3-button-clicked")] + private void ScrollingTextText3ButtonClicked() + { + AlwaysScroll = true; + ChangeableText = "short always scroll text example"; + } + + [UIAction("pause-duration-button-clicked")] + private void ScrollingTextPauseDurationButtonClicked() + { + if (PauseDuration < 4f) + PauseDuration = 4f; + else + PauseDuration = 1f; + } + + [UIAction("animation-type-button-clicked")] + private void ScrollingTextAnimationTypeButtonClicked() + { + if (AnimationType == ScrollingText.ScrollAnimationType.ForwardAndReverse) + AnimationType = ScrollingText.ScrollAnimationType.Continuous; + else + AnimationType = ScrollingText.ScrollAnimationType.ForwardAndReverse; + } + + [UIAction("scroll-speed-button-clicked")] + private void ScrollingTextScrollSpeedButtonClicked() + { + if (ScrollSpeed < 15f) + ScrollSpeed = 15f; + else + ScrollSpeed = 5f; + } } public class TestListObject { diff --git a/BeatSaberMarkupLanguage/Views/test.bsml b/BeatSaberMarkupLanguage/Views/test.bsml index f5696e9b..6502100b 100644 --- a/BeatSaberMarkupLanguage/Views/test.bsml +++ b/BeatSaberMarkupLanguage/Views/test.bsml @@ -1,7 +1,7 @@ - + @@ -126,4 +126,81 @@ + + + +