using System.Collections; using UnityEngine; using UnityEngine.UI; using TMPro; namespace TheIsland.Visual { /// /// Enhanced speech bubble with pop-in animation and auto-hide. /// Can be used as a standalone prefab or created programmatically. /// public class SpeechBubble : MonoBehaviour { #region Configuration [Header("Visual Settings")] [SerializeField] private float maxWidth = 350f; [SerializeField] private float padding = 20f; [SerializeField] private Color bubbleColor = new Color(1f, 1f, 1f, 0.95f); [SerializeField] private Color textColor = new Color(0.15f, 0.15f, 0.15f, 1f); [SerializeField] private Color outlineColor = new Color(0.3f, 0.3f, 0.3f, 1f); [Header("Animation Settings")] [SerializeField] private float popInDuration = 0.25f; [SerializeField] private float displayDuration = 5f; [SerializeField] private float fadeOutDuration = 0.3f; [SerializeField] private AnimationCurve popInCurve = AnimationCurve.EaseInOut(0, 0, 1, 1); [Header("Bounce Effect")] [SerializeField] private bool enableBounce = true; [SerializeField] private float bounceScale = 1.1f; [SerializeField] private float bounceBackDuration = 0.1f; [Header("Typewriter Effect")] [SerializeField] private bool enableTypewriter = false; [SerializeField] private float typewriterSpeed = 30f; // characters per second #endregion #region UI References private RectTransform _rectTransform; private Image _bubbleBackground; private Image _bubbleOutline; private TextMeshProUGUI _textComponent; private GameObject _tailObject; private CanvasGroup _canvasGroup; #endregion #region State private Coroutine _currentAnimation; private Coroutine _autoHideCoroutine; private string _fullText; private bool _isShowing; #endregion #region Properties public bool IsShowing => _isShowing; public float DisplayDuration { get => displayDuration; set => displayDuration = value; } #endregion #region Unity Lifecycle private void Awake() { CreateBubbleUI(); // Start hidden transform.localScale = Vector3.zero; _isShowing = false; } #endregion #region UI Creation private void CreateBubbleUI() { // Ensure we have a RectTransform _rectTransform = GetComponent(); if (_rectTransform == null) { _rectTransform = gameObject.AddComponent(); } _rectTransform.sizeDelta = new Vector2(maxWidth, 80); // Add CanvasGroup for fading _canvasGroup = gameObject.AddComponent(); // Create rounded rect sprite for bubble Sprite roundedSprite = CreateRoundedBubbleSprite(32, 32, 10); // Create outline (slightly larger background) var outlineObj = new GameObject("Outline"); outlineObj.transform.SetParent(transform); outlineObj.transform.localPosition = Vector3.zero; outlineObj.transform.localRotation = Quaternion.identity; outlineObj.transform.localScale = Vector3.one; _bubbleOutline = outlineObj.AddComponent(); _bubbleOutline.sprite = roundedSprite; _bubbleOutline.type = Image.Type.Sliced; _bubbleOutline.color = outlineColor; var outlineRect = outlineObj.GetComponent(); outlineRect.anchorMin = Vector2.zero; outlineRect.anchorMax = Vector2.one; outlineRect.offsetMin = new Vector2(-3, -3); outlineRect.offsetMax = new Vector2(3, 3); // Create main background var bgObj = new GameObject("Background"); bgObj.transform.SetParent(transform); bgObj.transform.localPosition = Vector3.zero; bgObj.transform.localRotation = Quaternion.identity; bgObj.transform.localScale = Vector3.one; _bubbleBackground = bgObj.AddComponent(); _bubbleBackground.sprite = roundedSprite; _bubbleBackground.type = Image.Type.Sliced; _bubbleBackground.color = bubbleColor; var bgRect = bgObj.GetComponent(); bgRect.anchorMin = Vector2.zero; bgRect.anchorMax = Vector2.one; bgRect.offsetMin = Vector2.zero; bgRect.offsetMax = Vector2.zero; // Create text var textObj = new GameObject("Text"); textObj.transform.SetParent(transform); textObj.transform.localPosition = Vector3.zero; textObj.transform.localRotation = Quaternion.identity; textObj.transform.localScale = Vector3.one; _textComponent = textObj.AddComponent(); _textComponent.fontSize = 22; _textComponent.color = textColor; _textComponent.alignment = TextAlignmentOptions.Center; _textComponent.textWrappingMode = TextWrappingModes.Normal; _textComponent.overflowMode = TextOverflowModes.Ellipsis; _textComponent.margin = new Vector4(padding, padding * 0.5f, padding, padding * 0.5f); var textRect = _textComponent.rectTransform; textRect.anchorMin = Vector2.zero; textRect.anchorMax = Vector2.one; textRect.offsetMin = Vector2.zero; textRect.offsetMax = Vector2.zero; // Create tail (triangle pointing down) _tailObject = CreateTail(); } private GameObject CreateTail() { var tail = new GameObject("Tail"); tail.transform.SetParent(transform); tail.transform.localRotation = Quaternion.identity; tail.transform.localScale = Vector3.one; var tailRect = tail.AddComponent(); tailRect.anchorMin = new Vector2(0.5f, 0); tailRect.anchorMax = new Vector2(0.5f, 0); tailRect.pivot = new Vector2(0.5f, 1); tailRect.anchoredPosition = new Vector2(0, 0); tailRect.sizeDelta = new Vector2(24, 16); // Create triangle sprite for tail var tailImage = tail.AddComponent(); tailImage.sprite = CreateTriangleSprite(24, 16); tailImage.color = bubbleColor; return tail; } private Sprite CreateRoundedBubbleSprite(int width, int height, int radius) { Texture2D tex = new Texture2D(width, height); tex.filterMode = FilterMode.Bilinear; Color[] pixels = new Color[width * height]; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { bool inRect = true; // Check corners for rounding if (x < radius && y < radius) { inRect = Vector2.Distance(new Vector2(x, y), new Vector2(radius, radius)) <= radius; } else if (x >= width - radius && y < radius) { inRect = Vector2.Distance(new Vector2(x, y), new Vector2(width - radius - 1, radius)) <= radius; } else if (x < radius && y >= height - radius) { inRect = Vector2.Distance(new Vector2(x, y), new Vector2(radius, height - radius - 1)) <= radius; } else if (x >= width - radius && y >= height - radius) { inRect = Vector2.Distance(new Vector2(x, y), new Vector2(width - radius - 1, height - radius - 1)) <= radius; } pixels[y * width + x] = inRect ? Color.white : Color.clear; } } tex.SetPixels(pixels); tex.Apply(); return Sprite.Create(tex, new Rect(0, 0, width, height), new Vector2(0.5f, 0.5f), 100f, 0, SpriteMeshType.FullRect, new Vector4(radius, radius, radius, radius)); } private Sprite CreateTriangleSprite(int width, int height) { Texture2D tex = new Texture2D(width, height); tex.filterMode = FilterMode.Bilinear; Color[] pixels = new Color[width * height]; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { // Triangle pointing down float t = (float)y / height; float halfWidth = (width / 2f) * (1 - t); float center = width / 2f; if (x >= center - halfWidth && x <= center + halfWidth) { pixels[y * width + x] = Color.white; } else { pixels[y * width + x] = Color.clear; } } } tex.SetPixels(pixels); tex.Apply(); return Sprite.Create(tex, new Rect(0, 0, width, height), new Vector2(0.5f, 1f), 100f); } #endregion #region Public API /// /// Setup and show the speech bubble with the given text. /// public void Setup(string text) { _fullText = text; // Stop any existing animations StopAllAnimations(); // Set text (either immediate or typewriter) if (enableTypewriter) { _textComponent.text = ""; StartCoroutine(TypewriterEffect(text)); } else { _textComponent.text = text; } // Auto-size the bubble based on text AdjustSizeToContent(); // Start show animation _currentAnimation = StartCoroutine(PopInAnimation()); // Schedule auto-hide _autoHideCoroutine = StartCoroutine(AutoHideAfterDelay()); _isShowing = true; Debug.Log($"[SpeechBubble] Showing: \"{text}\""); } /// /// Immediately hide the bubble. /// public void Hide() { StopAllAnimations(); StartCoroutine(FadeOutAnimation()); } /// /// Update bubble colors at runtime. /// public void SetColors(Color bubble, Color text, Color outline) { bubbleColor = bubble; textColor = text; outlineColor = outline; if (_bubbleBackground != null) _bubbleBackground.color = bubbleColor; if (_textComponent != null) _textComponent.color = textColor; if (_bubbleOutline != null) _bubbleOutline.color = outlineColor; } #endregion #region Animations private IEnumerator PopInAnimation() { float elapsed = 0f; _canvasGroup.alpha = 1f; // Pop in from zero to slightly larger than target while (elapsed < popInDuration) { elapsed += Time.deltaTime; float t = elapsed / popInDuration; float curveValue = popInCurve.Evaluate(t); float targetScale = enableBounce ? bounceScale : 1f; transform.localScale = Vector3.one * (curveValue * targetScale); yield return null; } // Bounce back to normal size if (enableBounce) { elapsed = 0f; while (elapsed < bounceBackDuration) { elapsed += Time.deltaTime; float t = elapsed / bounceBackDuration; float scale = Mathf.Lerp(bounceScale, 1f, t); transform.localScale = Vector3.one * scale; yield return null; } } transform.localScale = Vector3.one; _currentAnimation = null; } private IEnumerator FadeOutAnimation() { float elapsed = 0f; float startAlpha = _canvasGroup.alpha; Vector3 startScale = transform.localScale; while (elapsed < fadeOutDuration) { elapsed += Time.deltaTime; float t = elapsed / fadeOutDuration; _canvasGroup.alpha = Mathf.Lerp(startAlpha, 0f, t); transform.localScale = Vector3.Lerp(startScale, Vector3.zero, t); yield return null; } _canvasGroup.alpha = 0f; transform.localScale = Vector3.zero; _isShowing = false; _currentAnimation = null; } private IEnumerator TypewriterEffect(string text) { int charCount = 0; float timer = 0f; float charInterval = 1f / typewriterSpeed; while (charCount < text.Length) { timer += Time.deltaTime; while (timer >= charInterval && charCount < text.Length) { timer -= charInterval; charCount++; _textComponent.text = text.Substring(0, charCount); // Re-adjust size as text grows AdjustSizeToContent(); } yield return null; } _textComponent.text = text; } private IEnumerator AutoHideAfterDelay() { yield return new WaitForSeconds(displayDuration); Hide(); } #endregion #region Helpers private void StopAllAnimations() { if (_currentAnimation != null) { StopCoroutine(_currentAnimation); _currentAnimation = null; } if (_autoHideCoroutine != null) { StopCoroutine(_autoHideCoroutine); _autoHideCoroutine = null; } } private void AdjustSizeToContent() { if (_textComponent == null || _rectTransform == null) return; // Force mesh update to get accurate preferred values _textComponent.ForceMeshUpdate(); // Get preferred size Vector2 preferredSize = _textComponent.GetPreferredValues(); // Add padding float width = Mathf.Min(preferredSize.x + padding * 2, maxWidth); float height = preferredSize.y + padding; // If text is wider than max, recalculate height for wrapped text if (preferredSize.x > maxWidth - padding * 2) { _textComponent.ForceMeshUpdate(); height = _textComponent.GetPreferredValues(maxWidth - padding * 2, 0).y + padding; width = maxWidth; } _rectTransform.sizeDelta = new Vector2(width, height); } #endregion #region Static Factory /// /// Create a speech bubble as a child of the specified parent. /// public static SpeechBubble Create(Transform parent, Vector3 localPosition) { var bubbleObj = new GameObject("SpeechBubble"); bubbleObj.transform.SetParent(parent); bubbleObj.transform.localPosition = localPosition; bubbleObj.transform.localRotation = Quaternion.identity; bubbleObj.transform.localScale = Vector3.one; var bubble = bubbleObj.AddComponent(); return bubble; } #endregion } }