Files
the-island/unity-client/Assets/Scripts/SpeechBubble.cs
empty 6c66764cce feat: add gameplay enhancements and visual improvements
Backend:
- Add weather system with 6 weather types and transition probabilities
- Add day/night cycle (dawn, day, dusk, night) with phase modifiers
- Add mood system for agents (happy, neutral, sad, anxious)
- Add new commands: heal, talk, encourage, revive
- Add agent social interaction system with relationships
- Add casual mode with auto-revive and reduced decay rates

Frontend (Web):
- Add world state display (weather, time of day)
- Add mood bar to agent cards
- Add new action buttons for heal, encourage, talk, revive
- Handle new event types from server

Unity Client:
- Add EnvironmentManager with dynamic sky gradient and island scene
- Add WeatherEffects with rain, sun rays, fog, and heat particles
- Add SceneBootstrap for automatic visual system initialization
- Improve AgentVisual with better character sprites and animations
- Add breathing and bobbing idle animations
- Add character shadows
- Improve UI panels with rounded corners and borders
- Improve SpeechBubble with rounded corners and proper tail
- Add support for all new server events and commands

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:25:15 +08:00

459 lines
16 KiB
C#

using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
namespace TheIsland.Visual
{
/// <summary>
/// Enhanced speech bubble with pop-in animation and auto-hide.
/// Can be used as a standalone prefab or created programmatically.
/// </summary>
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<RectTransform>();
if (_rectTransform == null)
{
_rectTransform = gameObject.AddComponent<RectTransform>();
}
_rectTransform.sizeDelta = new Vector2(maxWidth, 80);
// Add CanvasGroup for fading
_canvasGroup = gameObject.AddComponent<CanvasGroup>();
// 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<Image>();
_bubbleOutline.sprite = roundedSprite;
_bubbleOutline.type = Image.Type.Sliced;
_bubbleOutline.color = outlineColor;
var outlineRect = outlineObj.GetComponent<RectTransform>();
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<Image>();
_bubbleBackground.sprite = roundedSprite;
_bubbleBackground.type = Image.Type.Sliced;
_bubbleBackground.color = bubbleColor;
var bgRect = bgObj.GetComponent<RectTransform>();
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<TextMeshProUGUI>();
_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<RectTransform>();
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<Image>();
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
/// <summary>
/// Setup and show the speech bubble with the given text.
/// </summary>
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}\"");
}
/// <summary>
/// Immediately hide the bubble.
/// </summary>
public void Hide()
{
StopAllAnimations();
StartCoroutine(FadeOutAnimation());
}
/// <summary>
/// Update bubble colors at runtime.
/// </summary>
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
/// <summary>
/// Create a speech bubble as a child of the specified parent.
/// </summary>
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<SpeechBubble>();
return bubble;
}
#endregion
}
}