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:
@@ -948,11 +948,17 @@ class GameEngine:
|
|||||||
# Use the existing process_comment method to handle commands
|
# Use the existing process_comment method to handle commands
|
||||||
await self.process_comment(user, text)
|
await self.process_comment(user, text)
|
||||||
|
|
||||||
async def process_bits(self, user: str, amount: int) -> None:
|
async def handle_gift(self, user: str, amount: int, gift_type: str = "bits") -> None:
|
||||||
"""Process Twitch bits and convert to game gold."""
|
"""
|
||||||
# 1 Bit = 1 Gold conversion rate
|
Handle a gift/donation (bits, subscription, or test).
|
||||||
gold_added = amount
|
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Name of the donor
|
||||||
|
amount: Value of the gift
|
||||||
|
gift_type: Type of gift (bits, test, etc.)
|
||||||
|
"""
|
||||||
|
# 1. Add gold to user
|
||||||
|
gold_added = amount
|
||||||
with get_db_session() as db:
|
with get_db_session() as db:
|
||||||
user_obj = self._get_or_create_user(db, user)
|
user_obj = self._get_or_create_user(db, user)
|
||||||
user_obj.gold += gold_added
|
user_obj.gold += gold_added
|
||||||
@@ -960,14 +966,38 @@ class GameEngine:
|
|||||||
await self._broadcast_event(EventType.USER_UPDATE, {
|
await self._broadcast_event(EventType.USER_UPDATE, {
|
||||||
"user": user,
|
"user": user,
|
||||||
"gold": user_obj.gold,
|
"gold": user_obj.gold,
|
||||||
"message": f"{user} received {gold_added} gold from {amount} bits!"
|
"message": f"{user} received {gold_added} gold!"
|
||||||
})
|
})
|
||||||
|
|
||||||
# Also broadcast a special bits event for UI effects
|
# Check for alive agents for reaction
|
||||||
await self._broadcast_event("bits_received", {
|
alive_agents = db.query(Agent).filter(Agent.status == "Alive").all()
|
||||||
|
agent = random.choice(alive_agents) if alive_agents else None
|
||||||
|
# Extract data immediately to avoid DetachedInstanceError after session closes
|
||||||
|
agent_name = agent.name if agent else "Survivor"
|
||||||
|
agent_personality = agent.personality if agent else "friendly"
|
||||||
|
|
||||||
|
# 2. Generate AI gratitude
|
||||||
|
gratitude = await llm_service.generate_gratitude(
|
||||||
|
user=user,
|
||||||
|
amount=amount,
|
||||||
|
agent_name=agent_name,
|
||||||
|
agent_personality=agent_personality,
|
||||||
|
gift_name=gift_type
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Broadcast gift effect to Unity
|
||||||
|
await self._broadcast_event("gift_effect", {
|
||||||
"user": user,
|
"user": user,
|
||||||
"bits": amount,
|
"gift_type": gift_type,
|
||||||
"gold": gold_added
|
"value": amount,
|
||||||
|
"message": f"{user} sent {amount} {gift_type}!",
|
||||||
|
"agent_name": agent_name if agent else None,
|
||||||
|
"gratitude": gratitude,
|
||||||
|
"duration": 8.0
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info(f"Processed bits: {user} -> {amount} bits -> {gold_added} gold")
|
logger.info(f"Processed gift: {user} -> {amount} {gift_type} (Gratitude: {gratitude})")
|
||||||
|
|
||||||
|
async def process_bits(self, user: str, amount: int) -> None:
|
||||||
|
"""Deprecated: Use handle_gift instead."""
|
||||||
|
await self.handle_gift(user, amount, "bits")
|
||||||
|
|||||||
@@ -56,6 +56,24 @@ MOCK_REACTIONS = {
|
|||||||
"My stomach is eating itself...",
|
"My stomach is eating itself...",
|
||||||
"Is this how it ends? Starving on a beach?",
|
"Is this how it ends? Starving on a beach?",
|
||||||
],
|
],
|
||||||
|
"gratitude_arrogant": [
|
||||||
|
"Finally! A worthy tribute! {user}, you understand greatness!",
|
||||||
|
"About time someone recognized my value! Thanks, {user}!",
|
||||||
|
"Hmph, {user} knows quality when they see it. Much appreciated!",
|
||||||
|
"A gift for ME? Well, obviously. Thank you, {user}!",
|
||||||
|
],
|
||||||
|
"gratitude_humble": [
|
||||||
|
"Oh my gosh, {user}! You're too kind! Thank you so much!",
|
||||||
|
"Wow, {user}, I don't deserve this! You're amazing!",
|
||||||
|
"*tears up* {user}... this means the world to me!",
|
||||||
|
"Thank you, thank you {user}! You're the best!",
|
||||||
|
],
|
||||||
|
"gratitude_neutral": [
|
||||||
|
"Hey, thanks {user}! That's really generous of you!",
|
||||||
|
"Wow, {user}! Thank you so much for the support!",
|
||||||
|
"Appreciate it, {user}! You're awesome!",
|
||||||
|
"{user}, you're a legend! Thank you!",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Default model configuration
|
# Default model configuration
|
||||||
@@ -461,6 +479,92 @@ class LLMService:
|
|||||||
logger.error(f"LLM API error for social interaction: {e}")
|
logger.error(f"LLM API error for social interaction: {e}")
|
||||||
return f"{initiator_name}: ...\n{target_name}: ..."
|
return f"{initiator_name}: ...\n{target_name}: ..."
|
||||||
|
|
||||||
|
async def generate_gratitude(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
amount: int,
|
||||||
|
agent_name: str = "Survivor",
|
||||||
|
agent_personality: str = "friendly",
|
||||||
|
gift_name: str = "bits"
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate a special gratitude response for donations/gifts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Name of the user who gave the gift
|
||||||
|
amount: Amount of the gift
|
||||||
|
agent_name: Name of the agent (optional)
|
||||||
|
agent_personality: Personality of the agent (optional)
|
||||||
|
gift_name: Type of gift (bits, subscription, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An excited, grateful response from the agent
|
||||||
|
"""
|
||||||
|
personality = agent_personality.lower() if agent_personality else "friendly"
|
||||||
|
|
||||||
|
|
||||||
|
if self._mock_mode:
|
||||||
|
if "arrogant" in personality or "proud" in personality:
|
||||||
|
responses = MOCK_REACTIONS.get("gratitude_arrogant", [])
|
||||||
|
elif "humble" in personality or "shy" in personality or "kind" in personality:
|
||||||
|
responses = MOCK_REACTIONS.get("gratitude_humble", [])
|
||||||
|
else:
|
||||||
|
responses = MOCK_REACTIONS.get("gratitude_neutral", [])
|
||||||
|
|
||||||
|
if responses:
|
||||||
|
return random.choice(responses).format(user=user, amount=amount)
|
||||||
|
return f"Thank you so much, {user}! You're amazing!"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Customize tone based on personality
|
||||||
|
if "arrogant" in personality or "proud" in personality:
|
||||||
|
tone_instruction = (
|
||||||
|
"You are somewhat arrogant but still grateful. "
|
||||||
|
"React with confident excitement, like 'Finally, a worthy tribute!' "
|
||||||
|
"but still thank them."
|
||||||
|
)
|
||||||
|
elif "humble" in personality or "shy" in personality:
|
||||||
|
tone_instruction = (
|
||||||
|
"You are humble and easily moved. "
|
||||||
|
"React with overwhelming gratitude, maybe even get teary-eyed."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
tone_instruction = (
|
||||||
|
"React with genuine excitement and gratitude."
|
||||||
|
)
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
f"You are {agent_name}, a survivor on a deserted island. "
|
||||||
|
f"Personality: {personality if personality else 'friendly'}. "
|
||||||
|
f"A wealthy patron named {user} just gave you {amount} {gift_name}! "
|
||||||
|
f"{tone_instruction} "
|
||||||
|
f"Respond with extreme excitement and gratitude (max 15 words). "
|
||||||
|
f"Keep it fun and energetic!"
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
"model": self._model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": f"{user} just gave you {amount} {gift_name}! React!"}
|
||||||
|
],
|
||||||
|
"max_tokens": 40,
|
||||||
|
"temperature": 0.95,
|
||||||
|
}
|
||||||
|
if self._api_base:
|
||||||
|
kwargs["api_base"] = self._api_base
|
||||||
|
if self._api_key and not self._api_key_header:
|
||||||
|
kwargs["api_key"] = self._api_key
|
||||||
|
if self._extra_headers:
|
||||||
|
kwargs["extra_headers"] = self._extra_headers
|
||||||
|
|
||||||
|
response = await self._acompletion(**kwargs)
|
||||||
|
return response.choices[0].message.content.strip()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LLM API error for gratitude: {e}")
|
||||||
|
return f"Wow, thank you so much {user}! You're amazing!"
|
||||||
|
|
||||||
|
|
||||||
# Global instance for easy import
|
# Global instance for easy import
|
||||||
llm_service = LLMService()
|
llm_service = LLMService()
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
text = message.payload.get("message", "")
|
text = message.payload.get("message", "")
|
||||||
await engine.process_comment(user, text)
|
await engine.process_comment(user, text)
|
||||||
|
|
||||||
|
# Handle test gift action
|
||||||
|
elif message.action == "test_gift":
|
||||||
|
user = message.payload.get("user", "TestUser")
|
||||||
|
bits = int(message.payload.get("bits", 100))
|
||||||
|
# Trigger handle_gift in engine
|
||||||
|
await engine.handle_gift(user, bits, "bits")
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
manager.disconnect(websocket)
|
manager.disconnect(websocket)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Compatible with twitchio 2.x (IRC-based)
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from twitchio.ext import commands
|
from twitchio.ext import commands
|
||||||
@@ -83,20 +84,19 @@ class TwitchBot(commands.Bot):
|
|||||||
if hasattr(message, 'tags') and message.tags:
|
if hasattr(message, 'tags') and message.tags:
|
||||||
bits = message.tags.get('bits')
|
bits = message.tags.get('bits')
|
||||||
if bits:
|
if bits:
|
||||||
try:
|
await self._handle_bits(username, int(bits))
|
||||||
bits_amount = int(bits)
|
|
||||||
logger.info(f"Received {bits_amount} bits from {username}")
|
|
||||||
await self._game_engine.process_bits(username, bits_amount)
|
|
||||||
|
|
||||||
# Send special gift effect to Unity
|
async def _handle_bits(self, username: str, bits_amount: int):
|
||||||
await self._game_engine._broadcast_event("gift_effect", {
|
"""
|
||||||
"type": "gift_effect",
|
Handle bits donation.
|
||||||
"user": username,
|
Delegates to game engine's unified gift handling.
|
||||||
"value": bits_amount,
|
"""
|
||||||
"message": f"{username} cheered {bits_amount} bits!"
|
try:
|
||||||
})
|
logger.info(f"Received {bits_amount} bits from {username}")
|
||||||
except (ValueError, TypeError) as e:
|
await self._game_engine.handle_gift(username, bits_amount, "bits")
|
||||||
logger.error(f"Error parsing bits amount: {e}")
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error handling bits: {e}")
|
||||||
|
|
||||||
async def event_command_error(self, context, error):
|
async def event_command_error(self, context, error):
|
||||||
"""Called when a command error occurs."""
|
"""Called when a command error occurs."""
|
||||||
@@ -110,3 +110,4 @@ class TwitchBot(commands.Bot):
|
|||||||
logger.error(f"Twitch bot error: {error}")
|
logger.error(f"Twitch bot error: {error}")
|
||||||
if data:
|
if data:
|
||||||
logger.debug(f"Error data: {data}")
|
logger.debug(f"Error data: {data}")
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -922,9 +922,15 @@ namespace TheIsland.Visual
|
|||||||
|
|
||||||
#region Speech
|
#region Speech
|
||||||
public void ShowSpeech(string text)
|
public void ShowSpeech(string text)
|
||||||
|
{
|
||||||
|
ShowSpeech(text, speechDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowSpeech(string text, float duration)
|
||||||
{
|
{
|
||||||
if (_speechBubble == null || !IsAlive) return;
|
if (_speechBubble == null || !IsAlive) return;
|
||||||
|
|
||||||
|
_speechBubble.DisplayDuration = duration;
|
||||||
_speechBubble.Setup(text);
|
_speechBubble.Setup(text);
|
||||||
Debug.Log($"[AgentVisual] {_currentData?.name} says: \"{text}\"");
|
Debug.Log($"[AgentVisual] {_currentData?.name} says: \"{text}\"");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ namespace TheIsland.Core
|
|||||||
network.OnTalk += HandleTalk;
|
network.OnTalk += HandleTalk;
|
||||||
network.OnRevive += HandleRevive;
|
network.OnRevive += HandleRevive;
|
||||||
network.OnSocialInteraction += HandleSocialInteraction;
|
network.OnSocialInteraction += HandleSocialInteraction;
|
||||||
|
network.OnGiftEffect += HandleGiftEffect; // Phase 8
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UnsubscribeFromNetworkEvents()
|
private void UnsubscribeFromNetworkEvents()
|
||||||
@@ -178,6 +179,7 @@ namespace TheIsland.Core
|
|||||||
network.OnTalk -= HandleTalk;
|
network.OnTalk -= HandleTalk;
|
||||||
network.OnRevive -= HandleRevive;
|
network.OnRevive -= HandleRevive;
|
||||||
network.OnSocialInteraction -= HandleSocialInteraction;
|
network.OnSocialInteraction -= HandleSocialInteraction;
|
||||||
|
network.OnGiftEffect -= HandleGiftEffect; // Phase 8
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -485,6 +487,54 @@ namespace TheIsland.Core
|
|||||||
initiatorUI.ShowSpeech(data.dialogue);
|
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
|
#endregion
|
||||||
|
|
||||||
#region Agent Management
|
#region Agent Management
|
||||||
|
|||||||
@@ -240,6 +240,21 @@ namespace TheIsland.Models
|
|||||||
public string dialogue;
|
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>
|
/// <summary>
|
||||||
/// Client message structure for sending to server.
|
/// Client message structure for sending to server.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -293,5 +308,8 @@ namespace TheIsland.Models
|
|||||||
public const string SOCIAL_INTERACTION = "social_interaction";
|
public const string SOCIAL_INTERACTION = "social_interaction";
|
||||||
public const string RELATIONSHIP_CHANGE = "relationship_change";
|
public const string RELATIONSHIP_CHANGE = "relationship_change";
|
||||||
public const string AUTO_REVIVE = "auto_revive";
|
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<ReviveEventData> OnRevive;
|
||||||
public event Action<SocialInteractionData> OnSocialInteraction;
|
public event Action<SocialInteractionData> OnSocialInteraction;
|
||||||
public event Action<WorldStateData> OnWorldUpdate;
|
public event Action<WorldStateData> OnWorldUpdate;
|
||||||
|
public event Action<GiftEffectData> OnGiftEffect; // Phase 8: Gift/Donation effects
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Private Fields
|
#region Private Fields
|
||||||
@@ -343,6 +344,11 @@ namespace TheIsland.Network
|
|||||||
OnSocialInteraction?.Invoke(socialData);
|
OnSocialInteraction?.Invoke(socialData);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case EventTypes.GIFT_EFFECT:
|
||||||
|
var giftData = JsonUtility.FromJson<GiftEffectData>(dataJson);
|
||||||
|
OnGiftEffect?.Invoke(giftData);
|
||||||
|
break;
|
||||||
|
|
||||||
case EventTypes.COMMENT:
|
case EventTypes.COMMENT:
|
||||||
// Comments can be logged but typically not displayed in 3D
|
// Comments can be logged but typically not displayed in 3D
|
||||||
Debug.Log($"[Chat] {json}");
|
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
|
||||||
@@ -487,6 +487,57 @@ Transform:
|
|||||||
m_Children: []
|
m_Children: []
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 0}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
|
--- !u!1 &2108464767
|
||||||
|
GameObject:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
serializedVersion: 6
|
||||||
|
m_Component:
|
||||||
|
- component: {fileID: 2108464769}
|
||||||
|
- component: {fileID: 2108464768}
|
||||||
|
m_Layer: 0
|
||||||
|
m_Name: VFXManager
|
||||||
|
m_TagString: Untagged
|
||||||
|
m_Icon: {fileID: 0}
|
||||||
|
m_NavMeshLayer: 0
|
||||||
|
m_StaticEditorFlags: 0
|
||||||
|
m_IsActive: 1
|
||||||
|
--- !u!114 &2108464768
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 2108464767}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: c07bfc9fddc8347ea826abf2adc4d44c, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::TheIsland.Visual.VFXManager
|
||||||
|
goldColor: {r: 1, g: 0.84, b: 0, a: 1}
|
||||||
|
goldParticleCount: 50
|
||||||
|
goldDuration: 2
|
||||||
|
heartColor: {r: 1, g: 0.2, b: 0.3, a: 1}
|
||||||
|
heartParticleCount: 30
|
||||||
|
heartDuration: 1.5
|
||||||
|
effectScale: 1
|
||||||
|
--- !u!4 &2108464769
|
||||||
|
Transform:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 2108464767}
|
||||||
|
serializedVersion: 2
|
||||||
|
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||||
|
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||||
|
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||||
|
m_ConstrainProportionsScale: 0
|
||||||
|
m_Children: []
|
||||||
|
m_Father: {fileID: 0}
|
||||||
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
--- !u!1660057539 &9223372036854775807
|
--- !u!1660057539 &9223372036854775807
|
||||||
SceneRoots:
|
SceneRoots:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -497,3 +548,4 @@ SceneRoots:
|
|||||||
- {fileID: 851065944}
|
- {fileID: 851065944}
|
||||||
- {fileID: 1562380643}
|
- {fileID: 1562380643}
|
||||||
- {fileID: 318018868}
|
- {fileID: 318018868}
|
||||||
|
- {fileID: 2108464769}
|
||||||
|
|||||||
Reference in New Issue
Block a user