feat: Phase 8 - VFX 和 AI 打赏反应系统
- Unity: 添加 VFXManager 实现金币雨和爱心爆炸特效 - Unity: NetworkManager 支持 GiftEffect 事件 - Unity: AgentVisual 支持自定义时长的 SpeechBubble - Backend: LLMService 支持生成个性化感谢语 - Backend: Engine 统一处理礼物逻辑 (handle_gift) - Backend: TwitchBot 接入新的礼物处理流程
This commit is contained in:
@@ -922,9 +922,15 @@ namespace TheIsland.Visual
|
||||
|
||||
#region Speech
|
||||
public void ShowSpeech(string text)
|
||||
{
|
||||
ShowSpeech(text, speechDuration);
|
||||
}
|
||||
|
||||
public void ShowSpeech(string text, float duration)
|
||||
{
|
||||
if (_speechBubble == null || !IsAlive) return;
|
||||
|
||||
_speechBubble.DisplayDuration = duration;
|
||||
_speechBubble.Setup(text);
|
||||
Debug.Log($"[AgentVisual] {_currentData?.name} says: \"{text}\"");
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ namespace TheIsland.Core
|
||||
network.OnTalk += HandleTalk;
|
||||
network.OnRevive += HandleRevive;
|
||||
network.OnSocialInteraction += HandleSocialInteraction;
|
||||
network.OnGiftEffect += HandleGiftEffect; // Phase 8
|
||||
}
|
||||
|
||||
private void UnsubscribeFromNetworkEvents()
|
||||
@@ -178,6 +179,7 @@ namespace TheIsland.Core
|
||||
network.OnTalk -= HandleTalk;
|
||||
network.OnRevive -= HandleRevive;
|
||||
network.OnSocialInteraction -= HandleSocialInteraction;
|
||||
network.OnGiftEffect -= HandleGiftEffect; // Phase 8
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -485,6 +487,54 @@ namespace TheIsland.Core
|
||||
initiatorUI.ShowSpeech(data.dialogue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle gift effect event (Twitch bits, subscriptions).
|
||||
/// Plays VFX and shows gratitude speech.
|
||||
/// </summary>
|
||||
private void HandleGiftEffect(GiftEffectData data)
|
||||
{
|
||||
Debug.Log($"[GameManager] Gift: {data.user} sent {data.value} {data.gift_type}");
|
||||
|
||||
// Find target agent position for VFX
|
||||
Vector3 effectPosition = Vector3.zero;
|
||||
int agentId = GetAgentIdByName(data.agent_name);
|
||||
|
||||
if (agentId >= 0 && _agentVisuals.TryGetValue(agentId, out AgentVisual agentVisual))
|
||||
{
|
||||
effectPosition = agentVisual.transform.position;
|
||||
|
||||
// Show gratitude speech with extended duration
|
||||
if (!string.IsNullOrEmpty(data.gratitude))
|
||||
{
|
||||
float duration = data.duration > 0 ? data.duration : 8f;
|
||||
agentVisual.ShowSpeech(data.gratitude, duration);
|
||||
}
|
||||
}
|
||||
else if (agentId >= 0 && _agentUIs.TryGetValue(agentId, out AgentUI agentUI))
|
||||
{
|
||||
effectPosition = agentUI.transform.position;
|
||||
|
||||
if (!string.IsNullOrEmpty(data.gratitude))
|
||||
{
|
||||
agentUI.ShowSpeech(data.gratitude);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default to center if no agent found
|
||||
effectPosition = new Vector3(0, 1, 0);
|
||||
}
|
||||
|
||||
// Play VFX
|
||||
if (VFXManager.Instance != null)
|
||||
{
|
||||
VFXManager.Instance.PlayEffect(data.gift_type, effectPosition);
|
||||
}
|
||||
|
||||
// Show notification
|
||||
ShowNotification(data.message);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Agent Management
|
||||
|
||||
@@ -240,6 +240,21 @@ namespace TheIsland.Models
|
||||
public string dialogue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gift effect event data (Twitch bits, subscriptions, etc.).
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class GiftEffectData
|
||||
{
|
||||
public string user;
|
||||
public string gift_type; // "bits", "heart", "sub"
|
||||
public int value;
|
||||
public string message;
|
||||
public string agent_name; // Target agent for the effect
|
||||
public string gratitude; // AI-generated thank you message
|
||||
public float duration; // How long to show the speech bubble (default 8s)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client message structure for sending to server.
|
||||
/// </summary>
|
||||
@@ -293,5 +308,8 @@ namespace TheIsland.Models
|
||||
public const string SOCIAL_INTERACTION = "social_interaction";
|
||||
public const string RELATIONSHIP_CHANGE = "relationship_change";
|
||||
public const string AUTO_REVIVE = "auto_revive";
|
||||
|
||||
// Gift/Donation system (Phase 8)
|
||||
public const string GIFT_EFFECT = "gift_effect";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ namespace TheIsland.Network
|
||||
public event Action<ReviveEventData> OnRevive;
|
||||
public event Action<SocialInteractionData> OnSocialInteraction;
|
||||
public event Action<WorldStateData> OnWorldUpdate;
|
||||
public event Action<GiftEffectData> OnGiftEffect; // Phase 8: Gift/Donation effects
|
||||
#endregion
|
||||
|
||||
#region Private Fields
|
||||
@@ -343,6 +344,11 @@ namespace TheIsland.Network
|
||||
OnSocialInteraction?.Invoke(socialData);
|
||||
break;
|
||||
|
||||
case EventTypes.GIFT_EFFECT:
|
||||
var giftData = JsonUtility.FromJson<GiftEffectData>(dataJson);
|
||||
OnGiftEffect?.Invoke(giftData);
|
||||
break;
|
||||
|
||||
case EventTypes.COMMENT:
|
||||
// Comments can be logged but typically not displayed in 3D
|
||||
Debug.Log($"[Chat] {json}");
|
||||
|
||||
317
unity-client/Assets/Scripts/Visual/VFXManager.cs
Normal file
317
unity-client/Assets/Scripts/Visual/VFXManager.cs
Normal file
@@ -0,0 +1,317 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace TheIsland.Visual
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton VFX Manager for handling particle effects.
|
||||
/// Creates procedural particle systems for gift effects.
|
||||
/// </summary>
|
||||
public class VFXManager : MonoBehaviour
|
||||
{
|
||||
#region Singleton
|
||||
private static VFXManager _instance;
|
||||
public static VFXManager Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = FindFirstObjectByType<VFXManager>();
|
||||
if (_instance == null)
|
||||
{
|
||||
var go = new GameObject("VFXManager");
|
||||
_instance = go.AddComponent<VFXManager>();
|
||||
}
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Settings
|
||||
[Header("Gold Rain Settings")]
|
||||
[SerializeField] private Color goldColor = new Color(1f, 0.84f, 0f); // Gold
|
||||
[SerializeField] private int goldParticleCount = 50;
|
||||
[SerializeField] private float goldDuration = 2f;
|
||||
|
||||
[Header("Heart Explosion Settings")]
|
||||
[SerializeField] private Color heartColor = new Color(1f, 0.2f, 0.3f); // Red/Pink
|
||||
[SerializeField] private int heartParticleCount = 30;
|
||||
[SerializeField] private float heartDuration = 1.5f;
|
||||
|
||||
[Header("General Settings")]
|
||||
[SerializeField] private float effectScale = 1f;
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
private void Awake()
|
||||
{
|
||||
if (_instance != null && _instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
_instance = this;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Public Methods
|
||||
/// <summary>
|
||||
/// Play gold coin rain effect at position.
|
||||
/// Used for Bits donations.
|
||||
/// </summary>
|
||||
public void PlayGoldRain(Vector3 position)
|
||||
{
|
||||
Debug.Log($"[VFXManager] Playing Gold Rain at {position}");
|
||||
var ps = CreateGoldRainSystem(position);
|
||||
ps.Play();
|
||||
Destroy(ps.gameObject, goldDuration + 0.5f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Play heart explosion effect at position.
|
||||
/// Used for subscription/heart gifts.
|
||||
/// </summary>
|
||||
public void PlayHeartExplosion(Vector3 position)
|
||||
{
|
||||
Debug.Log($"[VFXManager] Playing Heart Explosion at {position}");
|
||||
var ps = CreateHeartExplosionSystem(position);
|
||||
ps.Play();
|
||||
Destroy(ps.gameObject, heartDuration + 0.5f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Play an effect by type name.
|
||||
/// </summary>
|
||||
public void PlayEffect(string effectType, Vector3 position)
|
||||
{
|
||||
switch (effectType.ToLower())
|
||||
{
|
||||
case "bits":
|
||||
case "gold":
|
||||
case "goldrain":
|
||||
PlayGoldRain(position);
|
||||
break;
|
||||
case "heart":
|
||||
case "hearts":
|
||||
case "sub":
|
||||
case "subscription":
|
||||
PlayHeartExplosion(position);
|
||||
break;
|
||||
default:
|
||||
// Default to gold rain
|
||||
PlayGoldRain(position);
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Particle System Creation
|
||||
/// <summary>
|
||||
/// Create a procedural gold coin rain particle system.
|
||||
/// </summary>
|
||||
private ParticleSystem CreateGoldRainSystem(Vector3 position)
|
||||
{
|
||||
GameObject go = new GameObject("GoldRain_VFX");
|
||||
go.transform.position = position + Vector3.up * 3f; // Start above
|
||||
|
||||
ParticleSystem ps = go.AddComponent<ParticleSystem>();
|
||||
var main = ps.main;
|
||||
main.loop = false;
|
||||
main.duration = goldDuration;
|
||||
main.startLifetime = 1.5f;
|
||||
main.startSpeed = 2f;
|
||||
main.startSize = 0.15f * effectScale;
|
||||
main.startColor = goldColor;
|
||||
main.gravityModifier = 1f;
|
||||
main.maxParticles = goldParticleCount;
|
||||
main.simulationSpace = ParticleSystemSimulationSpace.World;
|
||||
|
||||
// Emission - burst at start
|
||||
var emission = ps.emission;
|
||||
emission.enabled = true;
|
||||
emission.rateOverTime = 0;
|
||||
emission.SetBursts(new ParticleSystem.Burst[]
|
||||
{
|
||||
new ParticleSystem.Burst(0f, goldParticleCount)
|
||||
});
|
||||
|
||||
// Shape - spread from point
|
||||
var shape = ps.shape;
|
||||
shape.enabled = true;
|
||||
shape.shapeType = ParticleSystemShapeType.Circle;
|
||||
shape.radius = 1f * effectScale;
|
||||
|
||||
// Size over lifetime - shrink slightly
|
||||
var sizeOverLifetime = ps.sizeOverLifetime;
|
||||
sizeOverLifetime.enabled = true;
|
||||
AnimationCurve sizeCurve = new AnimationCurve();
|
||||
sizeCurve.AddKey(0f, 1f);
|
||||
sizeCurve.AddKey(1f, 0.5f);
|
||||
sizeOverLifetime.size = new ParticleSystem.MinMaxCurve(1f, sizeCurve);
|
||||
|
||||
// Color over lifetime - fade out
|
||||
var colorOverLifetime = ps.colorOverLifetime;
|
||||
colorOverLifetime.enabled = true;
|
||||
Gradient gradient = new Gradient();
|
||||
gradient.SetKeys(
|
||||
new GradientColorKey[] {
|
||||
new GradientColorKey(goldColor, 0f),
|
||||
new GradientColorKey(goldColor, 0.7f)
|
||||
},
|
||||
new GradientAlphaKey[] {
|
||||
new GradientAlphaKey(1f, 0f),
|
||||
new GradientAlphaKey(1f, 0.5f),
|
||||
new GradientAlphaKey(0f, 1f)
|
||||
}
|
||||
);
|
||||
colorOverLifetime.color = gradient;
|
||||
|
||||
// Rotation - spin
|
||||
var rotationOverLifetime = ps.rotationOverLifetime;
|
||||
rotationOverLifetime.enabled = true;
|
||||
rotationOverLifetime.z = new ParticleSystem.MinMaxCurve(-180f, 180f);
|
||||
|
||||
// Renderer - use default sprite
|
||||
var renderer = go.GetComponent<ParticleSystemRenderer>();
|
||||
renderer.renderMode = ParticleSystemRenderMode.Billboard;
|
||||
renderer.material = CreateParticleMaterial(goldColor);
|
||||
|
||||
return ps;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a procedural heart explosion particle system.
|
||||
/// </summary>
|
||||
private ParticleSystem CreateHeartExplosionSystem(Vector3 position)
|
||||
{
|
||||
GameObject go = new GameObject("HeartExplosion_VFX");
|
||||
go.transform.position = position + Vector3.up * 1.5f;
|
||||
|
||||
ParticleSystem ps = go.AddComponent<ParticleSystem>();
|
||||
var main = ps.main;
|
||||
main.loop = false;
|
||||
main.duration = heartDuration;
|
||||
main.startLifetime = 1.2f;
|
||||
main.startSpeed = new ParticleSystem.MinMaxCurve(3f, 5f);
|
||||
main.startSize = 0.2f * effectScale;
|
||||
main.startColor = heartColor;
|
||||
main.gravityModifier = -0.3f; // Float up slightly
|
||||
main.maxParticles = heartParticleCount;
|
||||
main.simulationSpace = ParticleSystemSimulationSpace.World;
|
||||
|
||||
// Emission - burst at start
|
||||
var emission = ps.emission;
|
||||
emission.enabled = true;
|
||||
emission.rateOverTime = 0;
|
||||
emission.SetBursts(new ParticleSystem.Burst[]
|
||||
{
|
||||
new ParticleSystem.Burst(0f, heartParticleCount)
|
||||
});
|
||||
|
||||
// Shape - explode outwards from sphere
|
||||
var shape = ps.shape;
|
||||
shape.enabled = true;
|
||||
shape.shapeType = ParticleSystemShapeType.Sphere;
|
||||
shape.radius = 0.3f * effectScale;
|
||||
|
||||
// Size over lifetime - grow then shrink
|
||||
var sizeOverLifetime = ps.sizeOverLifetime;
|
||||
sizeOverLifetime.enabled = true;
|
||||
AnimationCurve sizeCurve = new AnimationCurve();
|
||||
sizeCurve.AddKey(0f, 0.5f);
|
||||
sizeCurve.AddKey(0.3f, 1.2f);
|
||||
sizeCurve.AddKey(1f, 0.2f);
|
||||
sizeOverLifetime.size = new ParticleSystem.MinMaxCurve(1f, sizeCurve);
|
||||
|
||||
// Color over lifetime - vibrant to fade
|
||||
var colorOverLifetime = ps.colorOverLifetime;
|
||||
colorOverLifetime.enabled = true;
|
||||
Gradient gradient = new Gradient();
|
||||
gradient.SetKeys(
|
||||
new GradientColorKey[] {
|
||||
new GradientColorKey(heartColor, 0f),
|
||||
new GradientColorKey(new Color(1f, 0.5f, 0.6f), 0.5f),
|
||||
new GradientColorKey(heartColor, 1f)
|
||||
},
|
||||
new GradientAlphaKey[] {
|
||||
new GradientAlphaKey(1f, 0f),
|
||||
new GradientAlphaKey(1f, 0.4f),
|
||||
new GradientAlphaKey(0f, 1f)
|
||||
}
|
||||
);
|
||||
colorOverLifetime.color = gradient;
|
||||
|
||||
// Rotation - gentle spin
|
||||
var rotationOverLifetime = ps.rotationOverLifetime;
|
||||
rotationOverLifetime.enabled = true;
|
||||
rotationOverLifetime.z = new ParticleSystem.MinMaxCurve(-90f, 90f);
|
||||
|
||||
// Renderer
|
||||
var renderer = go.GetComponent<ParticleSystemRenderer>();
|
||||
renderer.renderMode = ParticleSystemRenderMode.Billboard;
|
||||
renderer.material = CreateParticleMaterial(heartColor);
|
||||
|
||||
return ps;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a simple additive particle material.
|
||||
/// </summary>
|
||||
private Material CreateParticleMaterial(Color color)
|
||||
{
|
||||
// Use a built-in shader that works well for particles
|
||||
Shader shader = Shader.Find("Particles/Standard Unlit");
|
||||
if (shader == null)
|
||||
{
|
||||
shader = Shader.Find("Unlit/Color");
|
||||
}
|
||||
|
||||
Material mat = new Material(shader);
|
||||
mat.color = color;
|
||||
|
||||
// Enable additive blending for glow effect
|
||||
if (shader.name.Contains("Particles"))
|
||||
{
|
||||
mat.SetFloat("_Mode", 2); // Additive
|
||||
mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
|
||||
mat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.One);
|
||||
}
|
||||
|
||||
// Use default particle texture
|
||||
Texture2D particleTex = CreateDefaultParticleTexture();
|
||||
mat.mainTexture = particleTex;
|
||||
|
||||
return mat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a simple circular particle texture procedurally.
|
||||
/// </summary>
|
||||
private Texture2D CreateDefaultParticleTexture()
|
||||
{
|
||||
int size = 32;
|
||||
Texture2D tex = new Texture2D(size, size, TextureFormat.RGBA32, false);
|
||||
Color[] pixels = new Color[size * size];
|
||||
|
||||
float center = size / 2f;
|
||||
float radius = size / 2f - 1;
|
||||
|
||||
for (int y = 0; y < size; y++)
|
||||
{
|
||||
for (int x = 0; x < size; x++)
|
||||
{
|
||||
float dist = Vector2.Distance(new Vector2(x, y), new Vector2(center, center));
|
||||
float alpha = Mathf.Clamp01(1f - (dist / radius));
|
||||
alpha = alpha * alpha; // Softer falloff
|
||||
pixels[y * size + x] = new Color(1f, 1f, 1f, alpha);
|
||||
}
|
||||
}
|
||||
|
||||
tex.SetPixels(pixels);
|
||||
tex.Apply();
|
||||
return tex;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
2
unity-client/Assets/Scripts/Visual/VFXManager.cs.meta
Normal file
2
unity-client/Assets/Scripts/Visual/VFXManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c07bfc9fddc8347ea826abf2adc4d44c
|
||||
Reference in New Issue
Block a user