Compare commits
7 Commits
642205529c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8915a4b074 | ||
|
|
93fed8b9ca | ||
|
|
07acfa5801 | ||
|
|
da49223685 | ||
|
|
9d248f2cdb | ||
|
|
5476260efa | ||
|
|
adfd197451 |
16
README.md
16
README.md
@@ -16,6 +16,8 @@ the-island/
|
||||
│ ├── llm.py # LLM 集成 (对话生成)
|
||||
│ ├── memory_service.py # Agent 记忆管理服务
|
||||
│ ├── twitch_service.py # Twitch 聊天机器人
|
||||
│ ├── director_service.py # AI 导演服务 (叙事生成)
|
||||
│ ├── vote_manager.py # 投票管理器
|
||||
│ └── database.py # 数据库配置
|
||||
├── frontend/ # Web 调试客户端
|
||||
│ ├── app.js # JavaScript 客户端
|
||||
@@ -43,6 +45,8 @@ the-island/
|
||||
- **社交角色**: 领导者、追随者、独行者动态关系
|
||||
- **记忆系统**: Agent 会记住重要的互动和事件
|
||||
- **随机事件**: 风暴破坏、发现宝藏、野兽袭击等
|
||||
- **AI 导演系统**: 自动生成剧情事件,观众投票决定剧情走向
|
||||
- **叙事投票**: Twitch 观众通过 `!1` `!2` 命令参与剧情决策
|
||||
|
||||
### 玩家命令
|
||||
| 命令 | 格式 | 金币消耗 | 效果 |
|
||||
@@ -54,6 +58,8 @@ the-island/
|
||||
| revive | `revive <角色名>` | 10g | 复活死亡角色 |
|
||||
| check | `check` | 0g | 查看所有状态 |
|
||||
| reset | `reset` | 0g | 重置游戏 |
|
||||
| !1 / !A | `!1` 或 `!A` | 0g | 投票选择第一选项 |
|
||||
| !2 / !B | `!2` 或 `!B` | 0g | 投票选择第二选项 |
|
||||
|
||||
### AI 角色
|
||||
- **Jack** (勇敢) - 蓝色
|
||||
@@ -123,6 +129,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
| `EnvironmentManager.cs` | 环境场景 (沙滩、海洋、天空) |
|
||||
| `WeatherEffects.cs` | 天气粒子效果 (雨、雾、热浪) |
|
||||
| `Models.cs` | 数据模型 (Agent、WorldState、事件数据) |
|
||||
| `NarrativeUI.cs` | AI 导演叙事界面 (剧情卡片、投票进度条、倒计时) |
|
||||
|
||||
### 视觉特性
|
||||
- 程序化生成的 2.5D 角色精灵
|
||||
@@ -178,6 +185,15 @@ AGENT_ACTION # 角色执行行动 (采集/休息/社交等)
|
||||
CRAFT # 制作物品 (药品等)
|
||||
USE_ITEM # 使用物品
|
||||
RANDOM_EVENT # 随机事件 (风暴/宝藏/野兽等)
|
||||
|
||||
# AI 导演与叙事投票 (Phase 9)
|
||||
MODE_CHANGE # 游戏模式切换 (simulation/narrative/voting/resolution)
|
||||
NARRATIVE_PLOT # 导演生成的剧情事件
|
||||
VOTE_STARTED # 投票开始
|
||||
VOTE_UPDATE # 实时投票进度更新
|
||||
VOTE_ENDED # 投票结束
|
||||
VOTE_RESULT # 投票结果
|
||||
RESOLUTION_APPLIED # 剧情决议执行
|
||||
```
|
||||
|
||||
## Twitch 直播集成
|
||||
|
||||
562
backend/app/director_service.py
Normal file
562
backend/app/director_service.py
Normal file
@@ -0,0 +1,562 @@
|
||||
"""
|
||||
AI Director Service - Narrative Control Module (Phase 9).
|
||||
|
||||
The Director acts as the Dungeon Master for the survival drama,
|
||||
generating dramatic plot points and resolving audience votes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GameMode(str, Enum):
|
||||
"""Game engine operating modes."""
|
||||
SIMULATION = "simulation" # Normal agent behavior
|
||||
NARRATIVE = "narrative" # Director presents plot point
|
||||
VOTING = "voting" # Audience voting window
|
||||
RESOLUTION = "resolution" # Applying vote consequences
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlotChoice:
|
||||
"""A choice option in a plot point."""
|
||||
choice_id: str
|
||||
text: str
|
||||
effects: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlotPoint:
|
||||
"""A narrative event generated by the Director."""
|
||||
plot_id: str
|
||||
title: str
|
||||
description: str
|
||||
choices: list[PlotChoice]
|
||||
ttl_seconds: int = 60
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for broadcasting."""
|
||||
return {
|
||||
"plot_id": self.plot_id,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"choices": [
|
||||
{"choice_id": c.choice_id, "text": c.text}
|
||||
for c in self.choices
|
||||
],
|
||||
"ttl_seconds": self.ttl_seconds,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolutionResult:
|
||||
"""Result of resolving a plot point vote."""
|
||||
plot_id: str
|
||||
choice_id: str
|
||||
message: str
|
||||
effects: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for broadcasting."""
|
||||
return {
|
||||
"plot_id": self.plot_id,
|
||||
"choice_id": self.choice_id,
|
||||
"message": self.message,
|
||||
"effects_json": json.dumps(self.effects),
|
||||
}
|
||||
|
||||
|
||||
# Fallback templates when LLM is unavailable
|
||||
FALLBACK_PLOT_TEMPLATES = [
|
||||
{
|
||||
"title": "Mysterious Footprints",
|
||||
"description": "Strange footprints appear on the beach overnight. Someone - or something - has been watching the camp.",
|
||||
"choices": [
|
||||
PlotChoice("investigate", "Follow the tracks into the forest", {"risk": "medium", "reward": "discovery"}),
|
||||
PlotChoice("fortify", "Strengthen camp defenses and wait", {"safety": "high", "mood_delta": -5}),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Supply Shortage",
|
||||
"description": "The food stores are running dangerously low. Tension builds among the survivors.",
|
||||
"choices": [
|
||||
PlotChoice("ration", "Implement strict rationing for everyone", {"mood_delta": -10, "food_save": 2}),
|
||||
PlotChoice("hunt", "Send a group on a risky hunting expedition", {"risk": "high", "food_gain": 3}),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Storm Warning",
|
||||
"description": "Dark clouds gather on the horizon. A massive storm approaches the island.",
|
||||
"choices": [
|
||||
PlotChoice("shelter", "Everyone take shelter immediately", {"safety": "high", "mood_delta": 5}),
|
||||
PlotChoice("salvage", "Quickly gather supplies before the storm hits", {"risk": "medium", "resource_gain": 2}),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Trust Crisis",
|
||||
"description": "Accusations fly as valuable supplies go missing from the camp.",
|
||||
"choices": [
|
||||
PlotChoice("accuse", "Hold a trial to find the culprit", {"drama": "high", "relationship_delta": -5}),
|
||||
PlotChoice("forgive", "Call for unity and move on together", {"mood_delta": 3, "trust": "restored"}),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Rescue Signal",
|
||||
"description": "A faint light flickers on the distant horizon. Could it be a ship?",
|
||||
"choices": [
|
||||
PlotChoice("signal", "Build a massive signal fire on the beach", {"energy_delta": -15, "hope": "high"}),
|
||||
PlotChoice("wait", "Wait and observe - it could be dangerous", {"safety": "medium", "mood_delta": -3}),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class DirectorService:
|
||||
"""
|
||||
AI Director for generating and resolving narrative events.
|
||||
Uses LLM to create dramatic plot points based on world state.
|
||||
"""
|
||||
|
||||
def __init__(self, llm_service=None) -> None:
|
||||
"""
|
||||
Initialize the Director service.
|
||||
|
||||
Args:
|
||||
llm_service: Optional LLMService instance. If None, uses global instance.
|
||||
"""
|
||||
self._llm_service = llm_service
|
||||
self._rng = random.Random()
|
||||
self._current_plot: PlotPoint | None = None
|
||||
self._plot_history: list[str] = [] # Recent plot titles to avoid repetition
|
||||
|
||||
@property
|
||||
def llm(self):
|
||||
"""Lazy-load LLM service to avoid circular imports."""
|
||||
if self._llm_service is None:
|
||||
from .llm import llm_service
|
||||
self._llm_service = llm_service
|
||||
return self._llm_service
|
||||
|
||||
@property
|
||||
def current_plot(self) -> PlotPoint | None:
|
||||
"""Get the current active plot point."""
|
||||
return self._current_plot
|
||||
|
||||
def clear_current_plot(self) -> None:
|
||||
"""Clear the current plot after resolution."""
|
||||
if self._current_plot:
|
||||
self._plot_history.append(self._current_plot.title)
|
||||
# Keep only last 5 titles to avoid repetition
|
||||
self._plot_history = self._plot_history[-5:]
|
||||
self._current_plot = None
|
||||
|
||||
async def generate_plot_point(self, world_state: dict[str, Any]) -> PlotPoint:
|
||||
"""
|
||||
Generate a dramatic plot point based on current world state.
|
||||
|
||||
Args:
|
||||
world_state: Dictionary containing:
|
||||
- day: Current game day
|
||||
- weather: Current weather condition
|
||||
- time_of_day: dawn/day/dusk/night
|
||||
- alive_agents: List of alive agent summaries
|
||||
- recent_events: List of recent event descriptions
|
||||
- tension_level: low/medium/high (derived from deaths, resources, etc.)
|
||||
|
||||
Returns:
|
||||
PlotPoint with title, description, and 2 choices
|
||||
"""
|
||||
# Extract context
|
||||
day = world_state.get("day", 1)
|
||||
weather = world_state.get("weather", "Sunny")
|
||||
alive_count = len(world_state.get("alive_agents", []))
|
||||
recent_events = world_state.get("recent_events", [])
|
||||
tension_level = world_state.get("tension_level", "medium")
|
||||
mood_avg = world_state.get("mood_avg", 50)
|
||||
|
||||
# Build context summary
|
||||
agents_summary = ", ".join([
|
||||
f"{a.get('name', 'Unknown')} (HP:{a.get('hp', 0)})"
|
||||
for a in world_state.get("alive_agents", [])[:5]
|
||||
]) or "No agents alive"
|
||||
|
||||
events_summary = "; ".join(recent_events[-3:]) if recent_events else "Nothing notable recently"
|
||||
|
||||
# Try LLM generation first
|
||||
if not self.llm.is_mock_mode:
|
||||
try:
|
||||
plot = await self._generate_llm_plot(
|
||||
day=day,
|
||||
weather=weather,
|
||||
alive_count=alive_count,
|
||||
agents_summary=agents_summary,
|
||||
events_summary=events_summary,
|
||||
tension_level=tension_level,
|
||||
mood_avg=mood_avg,
|
||||
)
|
||||
if plot:
|
||||
self._current_plot = plot
|
||||
return plot
|
||||
except Exception as e:
|
||||
logger.error(f"LLM plot generation failed: {e}")
|
||||
|
||||
# Fallback to template-based generation
|
||||
plot = self._generate_fallback_plot(weather, tension_level, mood_avg)
|
||||
self._current_plot = plot
|
||||
return plot
|
||||
|
||||
async def _generate_llm_plot(
|
||||
self,
|
||||
day: int,
|
||||
weather: str,
|
||||
alive_count: int,
|
||||
agents_summary: str,
|
||||
events_summary: str,
|
||||
tension_level: str,
|
||||
mood_avg: int,
|
||||
) -> PlotPoint | None:
|
||||
"""Generate plot point using LLM."""
|
||||
|
||||
# Build the prompt for the AI Director
|
||||
system_prompt = f"""You are the AI Director for a survival drama on a deserted island.
|
||||
Your role is to create dramatic narrative moments that engage the audience.
|
||||
|
||||
CURRENT SITUATION:
|
||||
- Day {day} on the island
|
||||
- Weather: {weather}
|
||||
- Survivors: {alive_count} ({agents_summary})
|
||||
- Recent events: {events_summary}
|
||||
- Tension level: {tension_level}
|
||||
- Average mood: {mood_avg}/100
|
||||
|
||||
RECENTLY USED PLOTS (avoid these):
|
||||
{', '.join(self._plot_history) if self._plot_history else 'None yet'}
|
||||
|
||||
GUIDELINES:
|
||||
1. Create dramatic tension appropriate to the tension level
|
||||
2. Choices should have meaningful trade-offs
|
||||
3. Consider weather and mood in your narrative
|
||||
4. Keep descriptions cinematic but brief (under 50 words)
|
||||
|
||||
OUTPUT FORMAT (strict JSON):
|
||||
{{
|
||||
"title": "Brief dramatic title (3-5 words)",
|
||||
"description": "Cinematic description of the situation (under 50 words)",
|
||||
"choices": [
|
||||
{{"id": "choice_a", "text": "First option (under 15 words)", "effects": {{"mood_delta": 5}}}},
|
||||
{{"id": "choice_b", "text": "Second option (under 15 words)", "effects": {{"mood_delta": -5}}}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""The current tension is {tension_level}.
|
||||
{"Create an intense, high-stakes event!" if tension_level == "high" else "Create an interesting event to raise the drama." if tension_level == "low" else "Create a moderately dramatic event."}
|
||||
|
||||
Generate a plot point now. Output ONLY valid JSON, no explanation."""
|
||||
|
||||
try:
|
||||
# Use LLM service's internal acompletion
|
||||
kwargs = {
|
||||
"model": self.llm._model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
],
|
||||
"max_tokens": 300,
|
||||
"temperature": 0.9,
|
||||
}
|
||||
if self.llm._api_base:
|
||||
kwargs["api_base"] = self.llm._api_base
|
||||
if self.llm._api_key and not self.llm._api_key_header:
|
||||
kwargs["api_key"] = self.llm._api_key
|
||||
if self.llm._extra_headers:
|
||||
kwargs["extra_headers"] = self.llm._extra_headers
|
||||
|
||||
response = await self.llm._acompletion(**kwargs)
|
||||
content = response.choices[0].message.content.strip()
|
||||
|
||||
# Parse JSON response
|
||||
# Handle potential markdown code blocks
|
||||
if content.startswith("```"):
|
||||
content = content.split("```")[1]
|
||||
if content.startswith("json"):
|
||||
content = content[4:]
|
||||
|
||||
data = json.loads(content)
|
||||
|
||||
# Validate and construct PlotPoint
|
||||
choices = [
|
||||
PlotChoice(
|
||||
choice_id=c.get("id", f"choice_{i}"),
|
||||
text=c.get("text", "Unknown option"),
|
||||
effects=c.get("effects", {}),
|
||||
)
|
||||
for i, c in enumerate(data.get("choices", []))
|
||||
]
|
||||
|
||||
if len(choices) < 2:
|
||||
logger.warning("LLM returned fewer than 2 choices, using fallback")
|
||||
return None
|
||||
|
||||
return PlotPoint(
|
||||
plot_id=uuid.uuid4().hex,
|
||||
title=data.get("title", "Unexpected Event"),
|
||||
description=data.get("description", "Something happens..."),
|
||||
choices=choices[:2], # Limit to 2 choices
|
||||
ttl_seconds=60,
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse LLM JSON response: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"LLM plot generation error: {e}")
|
||||
return None
|
||||
|
||||
def _generate_fallback_plot(
|
||||
self,
|
||||
weather: str,
|
||||
tension_level: str,
|
||||
mood_avg: int,
|
||||
) -> PlotPoint:
|
||||
"""Generate plot point from templates when LLM is unavailable."""
|
||||
|
||||
# Filter templates based on context
|
||||
available = [t for t in FALLBACK_PLOT_TEMPLATES if t["title"] not in self._plot_history]
|
||||
|
||||
if not available:
|
||||
available = FALLBACK_PLOT_TEMPLATES
|
||||
|
||||
# Weight selection based on weather and tension
|
||||
if weather.lower() in ("stormy", "rainy", "thunder"):
|
||||
# Prefer storm-related plots
|
||||
storm_plots = [t for t in available if "storm" in t["title"].lower()]
|
||||
if storm_plots:
|
||||
available = storm_plots
|
||||
elif tension_level == "low" and mood_avg > 60:
|
||||
# Prefer dramatic plots to shake things up
|
||||
drama_plots = [t for t in available if "crisis" in t["title"].lower() or "trust" in t["title"].lower()]
|
||||
if drama_plots:
|
||||
available = drama_plots
|
||||
|
||||
template = self._rng.choice(available)
|
||||
|
||||
return PlotPoint(
|
||||
plot_id=uuid.uuid4().hex,
|
||||
title=template["title"],
|
||||
description=template["description"],
|
||||
choices=list(template["choices"]),
|
||||
ttl_seconds=60,
|
||||
)
|
||||
|
||||
async def resolve_vote(
|
||||
self,
|
||||
plot_point: PlotPoint,
|
||||
winning_choice_id: str,
|
||||
world_state: dict[str, Any],
|
||||
) -> ResolutionResult:
|
||||
"""
|
||||
Resolve the vote and generate consequences.
|
||||
|
||||
Args:
|
||||
plot_point: The PlotPoint that was voted on
|
||||
winning_choice_id: The ID of the winning choice
|
||||
world_state: Current world state for context
|
||||
|
||||
Returns:
|
||||
ResolutionResult with message and effects to apply
|
||||
"""
|
||||
# Find the winning choice
|
||||
winning_choice = next(
|
||||
(c for c in plot_point.choices if c.choice_id == winning_choice_id),
|
||||
plot_point.choices[0] # Fallback to first choice
|
||||
)
|
||||
|
||||
# Try LLM resolution first
|
||||
if not self.llm.is_mock_mode:
|
||||
try:
|
||||
result = await self._generate_llm_resolution(
|
||||
plot_point=plot_point,
|
||||
winning_choice=winning_choice,
|
||||
world_state=world_state,
|
||||
)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"LLM resolution failed: {e}")
|
||||
|
||||
# Fallback resolution
|
||||
return self._generate_fallback_resolution(plot_point, winning_choice)
|
||||
|
||||
async def _generate_llm_resolution(
|
||||
self,
|
||||
plot_point: PlotPoint,
|
||||
winning_choice: PlotChoice,
|
||||
world_state: dict[str, Any],
|
||||
) -> ResolutionResult | None:
|
||||
"""Generate resolution using LLM."""
|
||||
|
||||
agents_summary = ", ".join([
|
||||
a.get("name", "Unknown")
|
||||
for a in world_state.get("alive_agents", [])[:5]
|
||||
]) or "the survivors"
|
||||
|
||||
system_prompt = f"""You are the AI Director narrating the consequences of an audience vote.
|
||||
|
||||
THE SITUATION:
|
||||
{plot_point.description}
|
||||
|
||||
THE AUDIENCE VOTED FOR:
|
||||
"{winning_choice.text}"
|
||||
|
||||
SURVIVORS INVOLVED:
|
||||
{agents_summary}
|
||||
|
||||
GUIDELINES:
|
||||
1. Describe the immediate consequence dramatically
|
||||
2. Mention how the survivors react
|
||||
3. Keep it brief but impactful (under 40 words)
|
||||
4. The effects should feel meaningful
|
||||
|
||||
OUTPUT FORMAT (strict JSON):
|
||||
{{
|
||||
"message": "Dramatic description of what happens...",
|
||||
"effects": {{
|
||||
"mood_delta": -5,
|
||||
"hp_delta": 0,
|
||||
"energy_delta": -10,
|
||||
"item_gained": null,
|
||||
"item_lost": null,
|
||||
"relationship_change": null
|
||||
}}
|
||||
}}"""
|
||||
|
||||
try:
|
||||
kwargs = {
|
||||
"model": self.llm._model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"Narrate the consequence of choosing: {winning_choice.text}"}
|
||||
],
|
||||
"max_tokens": 200,
|
||||
"temperature": 0.8,
|
||||
}
|
||||
if self.llm._api_base:
|
||||
kwargs["api_base"] = self.llm._api_base
|
||||
if self.llm._api_key and not self.llm._api_key_header:
|
||||
kwargs["api_key"] = self.llm._api_key
|
||||
if self.llm._extra_headers:
|
||||
kwargs["extra_headers"] = self.llm._extra_headers
|
||||
|
||||
response = await self.llm._acompletion(**kwargs)
|
||||
content = response.choices[0].message.content.strip()
|
||||
|
||||
# Handle markdown code blocks
|
||||
if content.startswith("```"):
|
||||
content = content.split("```")[1]
|
||||
if content.startswith("json"):
|
||||
content = content[4:]
|
||||
|
||||
data = json.loads(content)
|
||||
|
||||
# Merge LLM effects with choice's predefined effects
|
||||
effects = {**winning_choice.effects, **data.get("effects", {})}
|
||||
|
||||
return ResolutionResult(
|
||||
plot_id=plot_point.plot_id,
|
||||
choice_id=winning_choice.choice_id,
|
||||
message=data.get("message", f"The survivors chose: {winning_choice.text}"),
|
||||
effects=effects,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LLM resolution error: {e}")
|
||||
return None
|
||||
|
||||
def _generate_fallback_resolution(
|
||||
self,
|
||||
plot_point: PlotPoint,
|
||||
winning_choice: PlotChoice,
|
||||
) -> ResolutionResult:
|
||||
"""Generate fallback resolution message."""
|
||||
|
||||
# Template-based resolution messages
|
||||
messages = [
|
||||
f"The decision is made! {winning_choice.text}",
|
||||
f"The survivors act: {winning_choice.text}",
|
||||
f"Following the audience's choice: {winning_choice.text}",
|
||||
]
|
||||
|
||||
return ResolutionResult(
|
||||
plot_id=plot_point.plot_id,
|
||||
choice_id=winning_choice.choice_id,
|
||||
message=self._rng.choice(messages),
|
||||
effects=dict(winning_choice.effects),
|
||||
)
|
||||
|
||||
def calculate_tension_level(self, world_state: dict[str, Any]) -> str:
|
||||
"""
|
||||
Calculate the current tension level based on world state.
|
||||
|
||||
Args:
|
||||
world_state: Dictionary with game state information
|
||||
|
||||
Returns:
|
||||
"low", "medium", or "high"
|
||||
"""
|
||||
score = 0
|
||||
|
||||
# Factor: Agent health
|
||||
alive_agents = world_state.get("alive_agents", [])
|
||||
if alive_agents:
|
||||
avg_hp = sum(a.get("hp", 100) for a in alive_agents) / len(alive_agents)
|
||||
if avg_hp < 30:
|
||||
score += 3
|
||||
elif avg_hp < 50:
|
||||
score += 2
|
||||
elif avg_hp < 70:
|
||||
score += 1
|
||||
|
||||
# Factor: Weather severity
|
||||
weather = world_state.get("weather", "").lower()
|
||||
if weather in ("stormy", "thunder"):
|
||||
score += 2
|
||||
elif weather in ("rainy",):
|
||||
score += 1
|
||||
|
||||
# Factor: Mood
|
||||
mood_avg = world_state.get("mood_avg", 50)
|
||||
if mood_avg < 30:
|
||||
score += 2
|
||||
elif mood_avg < 50:
|
||||
score += 1
|
||||
|
||||
# Factor: Recent deaths
|
||||
recent_deaths = world_state.get("recent_deaths", 0)
|
||||
score += min(recent_deaths * 2, 4)
|
||||
|
||||
# Factor: Low resources
|
||||
if world_state.get("resources_critical", False):
|
||||
score += 2
|
||||
|
||||
# Determine level
|
||||
if score >= 6:
|
||||
return "high"
|
||||
elif score >= 3:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
# Global instance
|
||||
director_service = DirectorService()
|
||||
@@ -15,6 +15,8 @@ from .database import init_db, get_db_session
|
||||
from .models import User, Agent, WorldState, GameConfig, AgentRelationship
|
||||
from .llm import llm_service
|
||||
from .memory_service import memory_service
|
||||
from .director_service import DirectorService, GameMode, PlotPoint
|
||||
from .vote_manager import VoteManager, VoteOption, VoteSnapshot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .server import ConnectionManager
|
||||
@@ -30,6 +32,7 @@ RESET_PATTERN = re.compile(r"(reset|重新开始|重置)", re.IGNORECASE)
|
||||
HEAL_PATTERN = re.compile(r"heal\s+(\w+)", re.IGNORECASE)
|
||||
TALK_PATTERN = re.compile(r"talk\s+(\w+)\s*(.*)?", re.IGNORECASE)
|
||||
ENCOURAGE_PATTERN = re.compile(r"encourage\s+(\w+)", re.IGNORECASE)
|
||||
LOVE_PATTERN = re.compile(r"love\s+(\w+)", re.IGNORECASE)
|
||||
REVIVE_PATTERN = re.compile(r"revive\s+(\w+)", re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
@@ -47,12 +50,23 @@ FEED_ENERGY_RESTORE = 20
|
||||
HEAL_COST = 15
|
||||
HEAL_HP_RESTORE = 30
|
||||
ENCOURAGE_COST = 5
|
||||
ENCOURAGE_COST = 5
|
||||
ENCOURAGE_MOOD_BOOST = 15
|
||||
LOVE_COST = 5
|
||||
LOVE_MOOD_BOOST = 20
|
||||
REVIVE_COST = 10 # Casual mode cost
|
||||
|
||||
INITIAL_USER_GOLD = 100
|
||||
IDLE_CHAT_PROBABILITY = 0.15
|
||||
|
||||
# =============================================================================
|
||||
# AI Director & Narrative Voting (Phase 9)
|
||||
# =============================================================================
|
||||
DIRECTOR_TRIGGER_INTERVAL = 60 # Ticks between narrative events (5 minutes at 5s/tick)
|
||||
DIRECTOR_MIN_ALIVE_AGENTS = 2 # Minimum alive agents to trigger narrative
|
||||
VOTING_DURATION_SECONDS = 60 # Duration of voting window
|
||||
VOTE_BROADCAST_INTERVAL = 1.0 # How often to broadcast vote updates
|
||||
|
||||
# =============================================================================
|
||||
# Day/Night cycle
|
||||
# =============================================================================
|
||||
@@ -126,7 +140,25 @@ class GameEngine:
|
||||
self._running = False
|
||||
self._tick_count = 0
|
||||
self._tick_interval = TICK_INTERVAL
|
||||
self._tick_interval = TICK_INTERVAL
|
||||
self._config: Optional[GameConfig] = None
|
||||
# Phase 22: Contextual Dialogue System
|
||||
# Key: agent_id (who needs to respond), Value: {partner_id, last_text, topic, expires_at_tick}
|
||||
self._active_conversations = {}
|
||||
|
||||
# Phase 9: AI Director & Narrative Voting
|
||||
self._director = DirectorService()
|
||||
self._vote_manager = VoteManager(
|
||||
duration_seconds=VOTING_DURATION_SECONDS,
|
||||
broadcast_interval=VOTE_BROADCAST_INTERVAL,
|
||||
)
|
||||
self._game_mode = GameMode.SIMULATION
|
||||
self._last_narrative_tick = 0
|
||||
self._current_plot: PlotPoint | None = None
|
||||
self._mode_change_tick = 0 # Tick when mode changed
|
||||
|
||||
# Set up vote broadcast callback
|
||||
self._vote_manager.set_broadcast_callback(self._on_vote_update)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
@@ -198,6 +230,14 @@ class GameEngine:
|
||||
event = GameEvent(event_type=event_type, timestamp=time.time(), data=data)
|
||||
await self._manager.broadcast(event)
|
||||
|
||||
async def _broadcast_vfx(self, effect: str, target_id: int = 0, message: str = "") -> None:
|
||||
"""Helper to broadcast visual effect events."""
|
||||
await self._broadcast_event(EventType.VFX_EVENT, {
|
||||
"effect": effect,
|
||||
"target_id": target_id,
|
||||
"message": message
|
||||
})
|
||||
|
||||
async def _broadcast_agents_status(self) -> None:
|
||||
"""Broadcast all agents' current status."""
|
||||
with get_db_session() as db:
|
||||
@@ -234,6 +274,222 @@ class GameEngine:
|
||||
if world:
|
||||
await self._broadcast_event(EventType.WORLD_UPDATE, world.to_dict())
|
||||
|
||||
# =========================================================================
|
||||
# AI Director & Narrative Voting (Phase 9)
|
||||
# =========================================================================
|
||||
async def _on_vote_update(self, snapshot: VoteSnapshot) -> None:
|
||||
"""Callback for broadcasting vote updates."""
|
||||
await self._broadcast_event(EventType.VOTE_UPDATE, snapshot.to_dict())
|
||||
|
||||
async def _set_game_mode(self, new_mode: GameMode, message: str = "") -> None:
|
||||
"""Switch game mode and broadcast the change."""
|
||||
old_mode = self._game_mode
|
||||
self._game_mode = new_mode
|
||||
self._mode_change_tick = self._tick_count
|
||||
|
||||
ends_at = 0.0
|
||||
if new_mode == GameMode.VOTING:
|
||||
session = self._vote_manager.current_session
|
||||
if session:
|
||||
ends_at = session.end_ts
|
||||
|
||||
await self._broadcast_event(EventType.MODE_CHANGE, {
|
||||
"mode": new_mode.value,
|
||||
"old_mode": old_mode.value,
|
||||
"message": message,
|
||||
"ends_at": ends_at,
|
||||
})
|
||||
|
||||
logger.info(f"Game mode changed: {old_mode.value} -> {new_mode.value}")
|
||||
|
||||
def _get_world_state_for_director(self) -> dict:
|
||||
"""Build world state context for the Director."""
|
||||
with get_db_session() as db:
|
||||
world = db.query(WorldState).first()
|
||||
agents = db.query(Agent).filter(Agent.status == "Alive").all()
|
||||
|
||||
alive_agents = [
|
||||
{"name": a.name, "hp": a.hp, "energy": a.energy, "mood": a.mood}
|
||||
for a in agents
|
||||
]
|
||||
|
||||
mood_avg = sum(a.mood for a in agents) / len(agents) if agents else 50
|
||||
|
||||
return {
|
||||
"day": world.day_count if world else 1,
|
||||
"weather": world.weather if world else "Sunny",
|
||||
"time_of_day": world.time_of_day if world else "day",
|
||||
"alive_agents": alive_agents,
|
||||
"mood_avg": mood_avg,
|
||||
"recent_events": [], # Could be populated from event history
|
||||
"tension_level": self._director.calculate_tension_level({
|
||||
"alive_agents": alive_agents,
|
||||
"weather": world.weather if world else "Sunny",
|
||||
"mood_avg": mood_avg,
|
||||
}),
|
||||
}
|
||||
|
||||
async def _should_trigger_narrative(self) -> bool:
|
||||
"""Check if conditions are met to trigger a narrative event."""
|
||||
# Only trigger in simulation mode
|
||||
if self._game_mode != GameMode.SIMULATION:
|
||||
return False
|
||||
|
||||
# Check tick interval
|
||||
ticks_since_last = self._tick_count - self._last_narrative_tick
|
||||
if ticks_since_last < DIRECTOR_TRIGGER_INTERVAL:
|
||||
return False
|
||||
|
||||
# Check minimum alive agents
|
||||
with get_db_session() as db:
|
||||
alive_count = db.query(Agent).filter(Agent.status == "Alive").count()
|
||||
if alive_count < DIRECTOR_MIN_ALIVE_AGENTS:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _trigger_narrative_event(self) -> None:
|
||||
"""Trigger a narrative event from the Director."""
|
||||
logger.info("Director triggering narrative event...")
|
||||
|
||||
# Switch to narrative mode
|
||||
await self._set_game_mode(GameMode.NARRATIVE, "The Director intervenes...")
|
||||
|
||||
# Generate plot point
|
||||
world_state = self._get_world_state_for_director()
|
||||
plot = await self._director.generate_plot_point(world_state)
|
||||
self._current_plot = plot
|
||||
self._last_narrative_tick = self._tick_count
|
||||
|
||||
# Broadcast narrative event
|
||||
await self._broadcast_event(EventType.NARRATIVE_PLOT, plot.to_dict())
|
||||
|
||||
logger.info(f"Narrative event: {plot.title}")
|
||||
|
||||
# Start voting session
|
||||
options = [
|
||||
VoteOption(choice_id=c.choice_id, text=c.text)
|
||||
for c in plot.choices
|
||||
]
|
||||
self._vote_manager.start_vote(options, duration_seconds=VOTING_DURATION_SECONDS)
|
||||
|
||||
# Broadcast vote started
|
||||
vote_data = self._vote_manager.get_vote_started_data()
|
||||
if vote_data:
|
||||
await self._broadcast_event(EventType.VOTE_STARTED, vote_data)
|
||||
|
||||
# Switch to voting mode
|
||||
await self._set_game_mode(
|
||||
GameMode.VOTING,
|
||||
f"Vote now! {plot.choices[0].text} or {plot.choices[1].text}"
|
||||
)
|
||||
|
||||
async def _process_voting_tick(self) -> None:
|
||||
"""Process voting phase - check if voting has ended."""
|
||||
if self._game_mode != GameMode.VOTING:
|
||||
return
|
||||
|
||||
result = self._vote_manager.maybe_finalize()
|
||||
if result:
|
||||
# Voting ended
|
||||
await self._broadcast_event(EventType.VOTE_ENDED, {
|
||||
"vote_id": result.vote_id,
|
||||
"total_votes": result.total_votes,
|
||||
})
|
||||
await self._broadcast_event(EventType.VOTE_RESULT, result.to_dict())
|
||||
|
||||
# Switch to resolution mode
|
||||
await self._set_game_mode(
|
||||
GameMode.RESOLUTION,
|
||||
f"The audience has spoken: {result.winning_choice_text}"
|
||||
)
|
||||
|
||||
# Process resolution
|
||||
await self._process_vote_result(result)
|
||||
|
||||
async def _process_vote_result(self, result) -> None:
|
||||
"""Process the voting result and apply consequences."""
|
||||
if not self._current_plot:
|
||||
logger.error("No current plot for resolution")
|
||||
await self._set_game_mode(GameMode.SIMULATION, "Returning to normal...")
|
||||
return
|
||||
|
||||
# Get resolution from Director
|
||||
world_state = self._get_world_state_for_director()
|
||||
resolution = await self._director.resolve_vote(
|
||||
plot_point=self._current_plot,
|
||||
winning_choice_id=result.winning_choice_id,
|
||||
world_state=world_state,
|
||||
)
|
||||
|
||||
# Apply effects
|
||||
await self._apply_resolution_effects(resolution.effects)
|
||||
|
||||
# Broadcast resolution
|
||||
await self._broadcast_event(EventType.RESOLUTION_APPLIED, resolution.to_dict())
|
||||
|
||||
logger.info(f"Resolution applied: {resolution.message}")
|
||||
|
||||
# Clear current plot
|
||||
self._director.clear_current_plot()
|
||||
self._current_plot = None
|
||||
|
||||
# Return to simulation after a brief pause
|
||||
await asyncio.sleep(3.0) # Let players read the resolution
|
||||
await self._set_game_mode(GameMode.SIMULATION, "The story continues...")
|
||||
|
||||
async def _apply_resolution_effects(self, effects: dict) -> None:
|
||||
"""Apply resolution effects to the game world."""
|
||||
def _coerce_int(value) -> int:
|
||||
"""Safely convert LLM output (string/float/int) to int."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
mood_delta = _coerce_int(effects.get("mood_delta", 0))
|
||||
hp_delta = _coerce_int(effects.get("hp_delta", 0))
|
||||
energy_delta = _coerce_int(effects.get("energy_delta", 0))
|
||||
|
||||
if not any([mood_delta, hp_delta, energy_delta]):
|
||||
return
|
||||
|
||||
with get_db_session() as db:
|
||||
agents = db.query(Agent).filter(Agent.status == "Alive").all()
|
||||
for agent in agents:
|
||||
if mood_delta:
|
||||
agent.mood = max(0, min(100, agent.mood + mood_delta))
|
||||
if hp_delta:
|
||||
agent.hp = max(0, min(100, agent.hp + hp_delta))
|
||||
if energy_delta:
|
||||
agent.energy = max(0, min(100, agent.energy + energy_delta))
|
||||
|
||||
logger.info(
|
||||
f"Applied resolution effects: mood={mood_delta}, "
|
||||
f"hp={hp_delta}, energy={energy_delta}"
|
||||
)
|
||||
|
||||
async def process_vote(self, voter_id: str, choice_index: int, source: str = "twitch") -> bool:
|
||||
"""
|
||||
Process a vote from Twitch or Unity.
|
||||
|
||||
Args:
|
||||
voter_id: Unique identifier for the voter
|
||||
choice_index: 0-indexed choice number
|
||||
source: Vote source ("twitch" or "unity")
|
||||
|
||||
Returns:
|
||||
True if vote was recorded
|
||||
"""
|
||||
if self._game_mode != GameMode.VOTING:
|
||||
return False
|
||||
|
||||
return self._vote_manager.cast_vote(voter_id, choice_index, source)
|
||||
|
||||
def parse_vote_command(self, message: str) -> int | None:
|
||||
"""Parse a message for vote commands. Returns choice index or None."""
|
||||
return self._vote_manager.parse_twitch_message(message)
|
||||
|
||||
# =========================================================================
|
||||
# Day/Night cycle (Phase 2)
|
||||
# =========================================================================
|
||||
@@ -665,13 +921,120 @@ class GameEngine:
|
||||
**interaction_data,
|
||||
"dialogue": dialogue
|
||||
})
|
||||
|
||||
# Phase 22: Contextual Dialogue - Store context for responder
|
||||
# Initiator just spoke. Target needs to respond next tick.
|
||||
initiator_id = interaction_data["initiator_id"]
|
||||
target_id = interaction_data["target_id"]
|
||||
|
||||
# 50% chance to continue the conversation (A -> B -> A)
|
||||
should_continue = True # For the first response (A->B), almost always yes unless "argue" maybe?
|
||||
|
||||
if should_continue:
|
||||
self._active_conversations[target_id] = {
|
||||
"partner_id": initiator_id,
|
||||
"last_text": dialogue,
|
||||
"topic": interaction_data["interaction_type"], # Rough topic
|
||||
"expires_at_tick": self._tick_count + 5 # Must respond within 5 ticks
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in social dialogue: {e}")
|
||||
|
||||
# =========================================================================
|
||||
# Autonomous Agency (Phase 13)
|
||||
# Economy / Altruism (Phase 23)
|
||||
# =========================================================================
|
||||
async def _process_altruism_tick(self) -> None:
|
||||
"""Process altruistic item sharing based on need."""
|
||||
if random.random() > 0.5: # 50% chance per tick to check
|
||||
return
|
||||
|
||||
with get_db_session() as db:
|
||||
agents = db.query(Agent).filter(Agent.status == "Alive").all()
|
||||
# Shuffle to avoid priority bias
|
||||
random.shuffle(agents)
|
||||
|
||||
for giver in agents:
|
||||
giver_inv = self._get_inventory(giver)
|
||||
|
||||
# Check surplus
|
||||
item_to_give = None
|
||||
# Give Herb if have plenty
|
||||
if giver_inv.get("herb", 0) >= 3:
|
||||
item_to_give = "herb"
|
||||
# Give Food if have plenty and energy is high
|
||||
elif giver_inv.get("food", 0) >= 1 and giver.energy > 80:
|
||||
item_to_give = "food"
|
||||
|
||||
if not item_to_give:
|
||||
continue
|
||||
|
||||
# Find needy neighbor
|
||||
for candidate in agents:
|
||||
if candidate.id == giver.id: continue
|
||||
|
||||
cand_inv = self._get_inventory(candidate)
|
||||
score = 0
|
||||
|
||||
if item_to_give == "herb":
|
||||
# High priority: Sick and no herbs
|
||||
if candidate.is_sick and cand_inv.get("herb", 0) == 0:
|
||||
score = 100
|
||||
elif item_to_give == "food":
|
||||
# High priority: Starving and no food
|
||||
if candidate.energy < 30 and cand_inv.get("food", 0) == 0:
|
||||
score = 50
|
||||
|
||||
if score > 0:
|
||||
# Check relationship (don't give to enemies)
|
||||
rel = db.query(AgentRelationship).filter(
|
||||
AgentRelationship.agent_from_id == giver.id,
|
||||
AgentRelationship.agent_to_id == candidate.id
|
||||
).first()
|
||||
type_ = rel.relationship_type if rel else "stranger"
|
||||
if type_ in ["rival", "enemy"]:
|
||||
continue
|
||||
|
||||
# Execute Give
|
||||
giver_inv[item_to_give] -= 1
|
||||
self._set_inventory(giver, giver_inv)
|
||||
|
||||
cand_inv[item_to_give] = cand_inv.get(item_to_give, 0) + 1
|
||||
self._set_inventory(candidate, cand_inv)
|
||||
|
||||
# Update Relationship (Giver -> Receiver)
|
||||
if not rel:
|
||||
rel = AgentRelationship(agent_from_id=giver.id, agent_to_id=candidate.id)
|
||||
db.add(rel)
|
||||
|
||||
rel.affection = min(100, rel.affection + 10)
|
||||
rel.trust = min(100, rel.trust + 5)
|
||||
rel.interaction_count += 1
|
||||
rel.update_relationship_type()
|
||||
|
||||
# Update Relationship (Receiver -> Giver)
|
||||
rel2 = db.query(AgentRelationship).filter(
|
||||
AgentRelationship.agent_from_id == candidate.id,
|
||||
AgentRelationship.agent_to_id == giver.id
|
||||
).first()
|
||||
if not rel2:
|
||||
rel2 = AgentRelationship(agent_from_id=candidate.id, agent_to_id=giver.id)
|
||||
db.add(rel2)
|
||||
rel2.affection = min(100, rel2.affection + 8)
|
||||
rel2.trust = min(100, rel2.trust + 3)
|
||||
rel2.update_relationship_type()
|
||||
|
||||
# Broadcast
|
||||
await self._broadcast_event(EventType.GIVE_ITEM, {
|
||||
"from_id": giver.id,
|
||||
"to_id": candidate.id,
|
||||
"item_type": item_to_give,
|
||||
"message": f"{giver.name} gave 1 {item_to_give} to {candidate.name}."
|
||||
})
|
||||
logger.info(f"{giver.name} gave {item_to_give} to {candidate.name}")
|
||||
|
||||
# One action per agent per tick
|
||||
break
|
||||
async def _process_activity_tick(self) -> None:
|
||||
"""Decide and execute autonomous agent actions."""
|
||||
# Only process activity every few ticks to avoid chaotic movement
|
||||
@@ -689,10 +1052,44 @@ class GameEngine:
|
||||
new_action = agent.current_action
|
||||
new_location = agent.location
|
||||
target_name = None
|
||||
target_name = None
|
||||
should_update = False
|
||||
|
||||
# Phase 22: Handle Pending Conversations (High Priority)
|
||||
if agent.id in self._active_conversations:
|
||||
pending = self._active_conversations[agent.id]
|
||||
# Check expiry
|
||||
if self._tick_count > pending["expires_at_tick"]:
|
||||
del self._active_conversations[agent.id]
|
||||
else:
|
||||
# Force response
|
||||
new_action = "Chat"
|
||||
new_location = agent.location # Stay put
|
||||
should_update = True
|
||||
|
||||
# Generate Response Immediately
|
||||
partner = db.query(Agent).filter(Agent.id == pending["partner_id"]).first()
|
||||
if partner:
|
||||
target_name = partner.name
|
||||
# Generate reply
|
||||
# We consume the pending state so we don't loop forever
|
||||
previous_text = pending["last_text"]
|
||||
del self._active_conversations[agent.id]
|
||||
|
||||
# Maybe add a chance for A to respond back to B (A-B-A)?
|
||||
# For simplicity, let's just do A-B for now, or 50% chance for A-B-A
|
||||
should_reply_back = random.random() < 0.5
|
||||
|
||||
# Extract values before async task (avoid detached session issues)
|
||||
asyncio.create_task(self._process_conversation_reply(
|
||||
agent.id, agent.name, partner.id, partner.name,
|
||||
previous_text, pending["topic"], should_reply_back
|
||||
))
|
||||
else:
|
||||
del self._active_conversations[agent.id]
|
||||
|
||||
# 1. Critical Needs (Override everything)
|
||||
if world.time_of_day == "night":
|
||||
elif world.time_of_day == "night":
|
||||
if agent.current_action != "Sleep":
|
||||
new_action = "Sleep"
|
||||
new_location = "campfire"
|
||||
@@ -739,9 +1136,18 @@ class GameEngine:
|
||||
friend = random.choice(potential_friends)
|
||||
new_location = "agent"
|
||||
target_name = friend.name
|
||||
target_name = friend.name
|
||||
should_update = True
|
||||
|
||||
# Phase 21: Social Interaction (Group Dance)
|
||||
# Phase 21-C: Advanced Social Locomotion (Follow)
|
||||
# If "follower" role (or just feeling social), follow a friend/leader
|
||||
elif agent.current_action not in ["Sleep", "Gather", "Dance", "Follow"] and random.random() < 0.15:
|
||||
target = self._find_follow_target(db, agent)
|
||||
if target:
|
||||
new_action = "Follow"
|
||||
new_location = "agent"
|
||||
target_name = target.name
|
||||
should_update = True
|
||||
# If Happy (>80) and near others, chance to start dancing
|
||||
elif agent.mood > 80 and agent.current_action != "Dance":
|
||||
# Check for nearby agents (same location)
|
||||
@@ -764,6 +1170,9 @@ class GameEngine:
|
||||
new_action = "Wander"
|
||||
new_location = "nearby" # Will be randomized in Unity/GameManager mapping
|
||||
should_update = True
|
||||
# Phase 23: Altruism - Give Item if needed (50% chance per tick to check)
|
||||
if random.random() < 0.5:
|
||||
await self._process_altruism_tick()
|
||||
elif random.random() < 0.1:
|
||||
new_action = "Idle"
|
||||
should_update = True
|
||||
@@ -844,8 +1253,39 @@ class GameEngine:
|
||||
return random.choice(["Hmm...", "Nice weather.", "Taking a walk."])
|
||||
elif action == "Wake Up":
|
||||
return "Good morning!"
|
||||
elif action == "Wake Up":
|
||||
return "Good morning!"
|
||||
elif action == "Dance":
|
||||
return random.choice(["Party time!", "Let's dance!", "Woo!"])
|
||||
elif action == "Follow":
|
||||
return f"Wait for me, {target}!"
|
||||
return ""
|
||||
|
||||
def _find_follow_target(self, db, agent: Agent) -> Optional[Agent]:
|
||||
"""Find a suitable target to follow (Leader or Friend)."""
|
||||
# 1. Prefer Leaders
|
||||
leader = db.query(Agent).filter(
|
||||
Agent.social_role == "leader",
|
||||
Agent.status == "Alive",
|
||||
Agent.id != agent.id
|
||||
).first()
|
||||
|
||||
if leader and random.random() < 0.7:
|
||||
return leader
|
||||
|
||||
# 2. Fallback to Close Friends
|
||||
rels = db.query(AgentRelationship).filter(
|
||||
AgentRelationship.agent_from_id == agent.id,
|
||||
AgentRelationship.relationship_type.in_(["close_friend", "friend"])
|
||||
).all()
|
||||
|
||||
if rels:
|
||||
r = random.choice(rels)
|
||||
target = db.query(Agent).filter(Agent.id == r.agent_to_id, Agent.status == "Alive").first()
|
||||
return target
|
||||
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# Inventory & Crafting (Phase 16)
|
||||
# =========================================================================
|
||||
@@ -927,6 +1367,91 @@ class GameEngine:
|
||||
})
|
||||
logger.info(f"Agent {agent.name} used medicine and is cured!")
|
||||
|
||||
# =========================================================================
|
||||
# Phase 24: Group Activities & Rituals
|
||||
# =========================================================================
|
||||
async def _process_campfire_gathering(self) -> None:
|
||||
"""Encourage agents to gather at campfire at night."""
|
||||
with get_db_session() as db:
|
||||
world = db.query(WorldState).first()
|
||||
if not world or world.time_of_day != "night":
|
||||
return
|
||||
|
||||
# Only run check occasionally to avoid spamming decision logic every tick if not needed
|
||||
if self._tick_count % 5 != 0:
|
||||
return
|
||||
|
||||
agents = db.query(Agent).filter(Agent.status == "Alive").all()
|
||||
for agent in agents:
|
||||
# If agent is critical, they will prioritize self-preservation in _process_activity_tick
|
||||
# But if they are just idle or wandering, we nudge them to campfire
|
||||
if agent.hp < 30 or agent.energy < 20 or agent.is_sick:
|
||||
continue
|
||||
|
||||
# If already there, stay
|
||||
if agent.location == "campfire":
|
||||
continue
|
||||
|
||||
# Force move to campfire "ritual"
|
||||
# We update their "current_action" so the next tick they don't override it immediately
|
||||
# But _process_activity_tick runs based on priorities.
|
||||
# To make this sticky, we might need a "GroupActivity" state or just rely on
|
||||
# tweaking the decision logic. For now, let's just forcefully set target if Idle.
|
||||
if agent.current_action in ["Idle", "Wander"]:
|
||||
agent.current_action = "Gathering"
|
||||
agent.location = "campfire" # Teleport logic or Move logic?
|
||||
# Actually, our decision logic sets location.
|
||||
# Let's just update location for simplicity as 'walking' is handled by frontend interpolation
|
||||
# if the distance is small, but massive jumps might look weird.
|
||||
# Ideally we set a goal. But for this engine, setting location IS the action result usually.
|
||||
pass
|
||||
|
||||
async def _process_group_activity(self) -> None:
|
||||
"""Trigger storytelling if enough agents are at the campfire."""
|
||||
# Only at night
|
||||
with get_db_session() as db:
|
||||
world = db.query(WorldState).first()
|
||||
if not world or world.time_of_day != "night":
|
||||
return
|
||||
|
||||
# Low probability check (don't spam stories)
|
||||
if random.random() > 0.05:
|
||||
return
|
||||
|
||||
# Check who is at campfire
|
||||
agents_at_fire = db.query(Agent).filter(
|
||||
Agent.status == "Alive",
|
||||
Agent.location == "campfire"
|
||||
).all()
|
||||
|
||||
if len(agents_at_fire) < 2:
|
||||
return
|
||||
|
||||
# Select Storyteller (Highest Mood or Extrovert)
|
||||
storyteller = max(agents_at_fire, key=lambda a: a.mood + (20 if a.social_tendency == 'extrovert' else 0))
|
||||
listeners = [a for a in agents_at_fire if a.id != storyteller.id]
|
||||
|
||||
# Generate Story
|
||||
topics = ["the ghost ship", "the ancient ruins", "a strange dream", "the day we arrived"]
|
||||
topic = random.choice(topics)
|
||||
|
||||
story_content = await llm_service.generate_story(storyteller.name, topic)
|
||||
|
||||
# Broadcast Event
|
||||
await self._broadcast_event(EventType.GROUP_ACTIVITY, {
|
||||
"activity_type": "storytelling",
|
||||
"storyteller_id": storyteller.id,
|
||||
"storyteller_name": storyteller.name,
|
||||
"listener_ids": [l.id for l in listeners],
|
||||
"content": story_content,
|
||||
"topic": topic
|
||||
})
|
||||
|
||||
# Boost Mood for everyone involved
|
||||
storyteller.mood = min(100, storyteller.mood + 10)
|
||||
for listener in listeners:
|
||||
listener.mood = min(100, listener.mood + 5)
|
||||
|
||||
# =========================================================================
|
||||
# LLM-powered agent speech
|
||||
# =========================================================================
|
||||
@@ -938,7 +1463,8 @@ class GameEngine:
|
||||
"""Fire-and-forget LLM call to generate agent speech."""
|
||||
try:
|
||||
class AgentSnapshot:
|
||||
def __init__(self, name, personality, hp, energy, mood, is_sheltered=False):
|
||||
def __init__(self, id, name, personality, hp, energy, mood, is_sheltered=False):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.personality = personality
|
||||
self.hp = hp
|
||||
@@ -947,7 +1473,7 @@ class GameEngine:
|
||||
self.is_sheltered = is_sheltered
|
||||
|
||||
agent_snapshot = AgentSnapshot(
|
||||
agent_name, agent_personality, agent_hp, agent_energy, agent_mood
|
||||
agent_id, agent_name, agent_personality, agent_hp, agent_energy, agent_mood
|
||||
)
|
||||
|
||||
text = await llm_service.generate_reaction(agent_snapshot, event_description, event_type)
|
||||
@@ -981,7 +1507,8 @@ class GameEngine:
|
||||
|
||||
try:
|
||||
class AgentSnapshot:
|
||||
def __init__(self, name, personality, hp, energy, mood, is_sheltered=False):
|
||||
def __init__(self, id, name, personality, hp, energy, mood, is_sheltered=False):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.personality = personality
|
||||
self.hp = hp
|
||||
@@ -990,7 +1517,7 @@ class GameEngine:
|
||||
self.is_sheltered = is_sheltered
|
||||
|
||||
agent_snapshot = AgentSnapshot(
|
||||
agent_data["name"], agent_data["personality"],
|
||||
agent_data["id"], agent_data["name"], agent_data["personality"],
|
||||
agent_data["hp"], agent_data["energy"], agent_data["mood"],
|
||||
agent_data.get("is_sheltered", False)
|
||||
)
|
||||
@@ -1045,13 +1572,16 @@ class GameEngine:
|
||||
|
||||
if feed_result:
|
||||
await self._broadcast_event(EventType.FEED, {
|
||||
"user": username, "agent_name": feed_result["agent_name"],
|
||||
"energy_restored": feed_result["actual_restore"],
|
||||
"agent_energy": feed_result["agent_energy"], "user_gold": feed_result["user_gold"],
|
||||
"user": username, "agent_name": feed_result['agent_name'],
|
||||
"energy_restored": feed_result['actual_restore'],
|
||||
"agent_energy": feed_result['agent_energy'], "user_gold": feed_result['user_gold'],
|
||||
"message": f"{username} fed {feed_result['agent_name']}!"
|
||||
})
|
||||
await self._broadcast_event(EventType.USER_UPDATE, {"user": username, "gold": feed_result["user_gold"]})
|
||||
|
||||
# VFX: Food Cloud
|
||||
await self._broadcast_vfx("food", feed_result["agent_id"], "")
|
||||
|
||||
asyncio.create_task(self._trigger_agent_speak(
|
||||
feed_result["agent_id"], feed_result["agent_name"],
|
||||
feed_result["agent_personality"], feed_result["agent_hp"],
|
||||
@@ -1138,6 +1668,43 @@ class GameEngine:
|
||||
f"User {username} encouraged you!", "encourage"
|
||||
))
|
||||
|
||||
async def _handle_love(self, username: str, agent_name: str) -> None:
|
||||
"""Handle love command (hearts)."""
|
||||
with get_db_session() as db:
|
||||
user = self._get_or_create_user(db, username)
|
||||
agent = db.query(Agent).filter(Agent.name.ilike(agent_name)).first()
|
||||
|
||||
if agent is None:
|
||||
await self._broadcast_event(EventType.ERROR, {"message": f"Agent '{agent_name}' not found"})
|
||||
return
|
||||
|
||||
if agent.status != "Alive":
|
||||
await self._broadcast_event(EventType.ERROR, {"message": f"{agent.name} is dead"})
|
||||
return
|
||||
|
||||
if user.gold < LOVE_COST:
|
||||
await self._broadcast_event(EventType.ERROR, {
|
||||
"user": username, "message": f"Not enough gold! Need {LOVE_COST}, have {user.gold}"
|
||||
})
|
||||
return
|
||||
|
||||
user.gold -= LOVE_COST
|
||||
old_mood = agent.mood
|
||||
agent.mood = min(100, agent.mood + LOVE_MOOD_BOOST)
|
||||
|
||||
# Update relationship (Phase 5 integration: could add affection)
|
||||
# For now just simple mood boost and FX
|
||||
|
||||
await self._broadcast_event(EventType.USER_UPDATE, {"user": username, "gold": user.gold})
|
||||
|
||||
# VFX: Hearts
|
||||
await self._broadcast_vfx("heart", agent.id, f"{username} sends love to {agent.name}!")
|
||||
|
||||
asyncio.create_task(self._trigger_agent_speak(
|
||||
agent.id, agent.name, agent.personality, agent.hp, agent.energy, agent.mood,
|
||||
f"User {username} sent you love!", "love"
|
||||
))
|
||||
|
||||
async def _handle_talk(self, username: str, agent_name: str, topic: str = "") -> None:
|
||||
"""Handle talk command - free conversation with agent."""
|
||||
with get_db_session() as db:
|
||||
@@ -1276,6 +1843,10 @@ class GameEngine:
|
||||
await self._handle_encourage(user, match.group(1))
|
||||
return
|
||||
|
||||
if match := LOVE_PATTERN.search(message):
|
||||
await self._handle_love(user, match.group(1))
|
||||
return
|
||||
|
||||
if match := REVIVE_PATTERN.search(message):
|
||||
await self._handle_revive(user, match.group(1))
|
||||
return
|
||||
@@ -1371,6 +1942,20 @@ class GameEngine:
|
||||
while self._running:
|
||||
self._tick_count += 1
|
||||
|
||||
# Phase 9: Check voting phase (always runs)
|
||||
await self._process_voting_tick()
|
||||
|
||||
# Phase 9: Check if we should trigger a narrative event
|
||||
if await self._should_trigger_narrative():
|
||||
await self._trigger_narrative_event()
|
||||
|
||||
# Skip simulation processing during narrative/voting/resolution modes
|
||||
if self._game_mode != GameMode.SIMULATION:
|
||||
await asyncio.sleep(self._tick_interval)
|
||||
continue
|
||||
|
||||
# ========== SIMULATION MODE PROCESSING ==========
|
||||
|
||||
# 1. Advance time (Phase 2)
|
||||
phase_change = await self._advance_time()
|
||||
if phase_change:
|
||||
@@ -1399,12 +1984,21 @@ class GameEngine:
|
||||
# 5. Update moods (Phase 3)
|
||||
await self._update_moods()
|
||||
|
||||
# Phase 24: Group Activities
|
||||
# Check for campfire time (Night)
|
||||
await self._process_campfire_gathering()
|
||||
# Check for storytelling events
|
||||
await self._process_group_activity()
|
||||
|
||||
# 6. Autonomous Activity (Phase 13)
|
||||
await self._process_activity_tick()
|
||||
|
||||
# 7. Social interactions (Phase 5)
|
||||
await self._process_social_tick()
|
||||
|
||||
# Phase 23: Altruism (Item Exchange)
|
||||
await self._process_altruism_tick()
|
||||
|
||||
# 8. Random Events (Phase 17-C)
|
||||
await self._process_random_events()
|
||||
|
||||
@@ -1434,7 +2028,8 @@ class GameEngine:
|
||||
"day": day,
|
||||
"time_of_day": time_of_day,
|
||||
"weather": weather,
|
||||
"alive_agents": alive_count
|
||||
"alive_agents": alive_count,
|
||||
"game_mode": self._game_mode.value # Phase 9: Include game mode
|
||||
})
|
||||
|
||||
await asyncio.sleep(self._tick_interval)
|
||||
@@ -1533,3 +2128,57 @@ class GameEngine:
|
||||
async def process_bits(self, user: str, amount: int) -> None:
|
||||
"""Deprecated: Use handle_gift instead."""
|
||||
await self.handle_gift(user, amount, "bits")
|
||||
async def _process_conversation_reply(
|
||||
self, responder_id: int, responder_name: str, partner_id: int, partner_name: str,
|
||||
previous_text: str, topic: str, should_reply_back: bool
|
||||
) -> None:
|
||||
"""Handle the secondary turn of a conversation."""
|
||||
try:
|
||||
# Relationship
|
||||
with get_db_session() as db:
|
||||
rel = db.query(AgentRelationship).filter(
|
||||
AgentRelationship.agent_from_id == responder_id,
|
||||
AgentRelationship.agent_to_id == partner_id
|
||||
).first()
|
||||
rel_type = rel.relationship_type if rel else "acquaintance"
|
||||
|
||||
# Basic world info
|
||||
world = db.query(WorldState).first()
|
||||
weather = world.weather if world else "Sunny"
|
||||
time_of_day = world.time_of_day if world else "day"
|
||||
|
||||
# Generate reply
|
||||
# We use the same generate_social_interaction but with previous_dialogue set
|
||||
# 'interaction_type' is reused as topic
|
||||
reply = await llm_service.generate_social_interaction(
|
||||
initiator_name=responder_name,
|
||||
target_name=partner_name,
|
||||
interaction_type=topic,
|
||||
relationship_type=rel_type,
|
||||
weather=weather,
|
||||
time_of_day=time_of_day,
|
||||
previous_dialogue=previous_text
|
||||
)
|
||||
|
||||
# Broadcast response
|
||||
await self._broadcast_event(EventType.SOCIAL_INTERACTION, {
|
||||
"initiator_id": responder_id,
|
||||
"initiator_name": responder_name,
|
||||
"target_id": partner_id,
|
||||
"target_name": partner_name,
|
||||
"interaction_type": "reply",
|
||||
"relationship_type": rel_type,
|
||||
"dialogue": reply
|
||||
})
|
||||
|
||||
# Chain next turn?
|
||||
if should_reply_back:
|
||||
self._active_conversations[partner_id] = {
|
||||
"partner_id": responder_id,
|
||||
"last_text": reply,
|
||||
"topic": topic,
|
||||
"expires_at_tick": self._tick_count + 5 # Must respond within 5 ticks
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in conversation reply: {e}")
|
||||
|
||||
@@ -404,7 +404,8 @@ class LLMService:
|
||||
interaction_type: str,
|
||||
relationship_type: str,
|
||||
weather: str = "Sunny",
|
||||
time_of_day: str = "day"
|
||||
time_of_day: str = "day",
|
||||
previous_dialogue: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Generate dialogue for social interaction between two agents.
|
||||
@@ -467,10 +468,20 @@ class LLMService:
|
||||
f"{initiator_name} and {target_name} {relationship_desc}. "
|
||||
f"It is {time_of_day} and the weather is {weather}. "
|
||||
f"{initiator_name} is {interaction_desc} {target_name}. "
|
||||
f"Write a brief, natural dialogue exchange (2-3 lines total). "
|
||||
f"Format: '{initiator_name}: [line]\\n{target_name}: [response]'"
|
||||
)
|
||||
|
||||
if previous_dialogue:
|
||||
system_prompt += (
|
||||
f"\nCONTEXT: {target_name} just said: '{previous_dialogue}'\n"
|
||||
f"Write a response from {initiator_name} to {target_name}. "
|
||||
f"Format: '{initiator_name}: [response]'"
|
||||
)
|
||||
else:
|
||||
system_prompt += (
|
||||
f"\nWrite a brief opening dialogue exchange (2-3 lines total). "
|
||||
f"Format: '{initiator_name}: [line]\\n{target_name}: [response]'"
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
"model": self._model,
|
||||
"messages": [
|
||||
@@ -494,6 +505,52 @@ class LLMService:
|
||||
logger.error(f"LLM API error for social interaction: {e}")
|
||||
return f"{initiator_name}: ...\n{target_name}: ..."
|
||||
|
||||
async def generate_story(
|
||||
self,
|
||||
storyteller_name: str,
|
||||
topic: str = "ghost_story"
|
||||
) -> str:
|
||||
"""
|
||||
Generate a short story for the campfire.
|
||||
"""
|
||||
if self._mock_mode:
|
||||
stories = [
|
||||
"Once upon a time, a ship crashed here...",
|
||||
"The elders say this island is haunted...",
|
||||
"I saw a strange light in the forest yesterday..."
|
||||
]
|
||||
return random.choice(stories)
|
||||
|
||||
try:
|
||||
system_prompt = (
|
||||
f"You are {storyteller_name}, a survivor telling a story at a campfire. "
|
||||
f"Topic: {topic}. "
|
||||
f"Keep it short (2-3 sentences), mysterious, and atmospheric."
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
"model": self._model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": "Tell us a story."}
|
||||
],
|
||||
"max_tokens": 100,
|
||||
"temperature": 1.0,
|
||||
}
|
||||
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 story: {e}")
|
||||
return "It was a dark and stormy night..."
|
||||
|
||||
async def generate_gratitude(
|
||||
self,
|
||||
user: str,
|
||||
|
||||
@@ -55,6 +55,26 @@ class EventType(str, Enum):
|
||||
# Random Events (Phase 17-C)
|
||||
RANDOM_EVENT = "random_event" # Random event occurred
|
||||
|
||||
# Economy (Phase 23)
|
||||
GIVE_ITEM = "give_item" # Agent gives item to another
|
||||
|
||||
# Group Activities (Phase 24)
|
||||
# Group Activities (Phase 24)
|
||||
GROUP_ACTIVITY = "group_activity" # Storytelling, dancing, etc.
|
||||
|
||||
# VFX & Gifts (Phase 8)
|
||||
VFX_EVENT = "vfx_event" # Visual effect trigger
|
||||
GIFT_EFFECT = "gift_effect" # Twitch bits/sub effect
|
||||
|
||||
# AI Director & Narrative Voting (Phase 9)
|
||||
MODE_CHANGE = "mode_change" # Game mode transition
|
||||
NARRATIVE_PLOT = "narrative_plot" # Director generated plot point
|
||||
VOTE_STARTED = "vote_started" # Voting session started
|
||||
VOTE_UPDATE = "vote_update" # Real-time vote count update
|
||||
VOTE_ENDED = "vote_ended" # Voting closed
|
||||
VOTE_RESULT = "vote_result" # Final voting result
|
||||
RESOLUTION_APPLIED = "resolution_applied" # Plot resolution executed
|
||||
|
||||
|
||||
class GameEvent(BaseModel):
|
||||
"""
|
||||
|
||||
@@ -74,6 +74,18 @@ class TwitchBot(commands.Bot):
|
||||
# Log the message for debugging
|
||||
logger.info(f"Twitch chat [{username}]: {content}")
|
||||
|
||||
# Phase 9: Check for vote commands first (!1, !2, !A, !B)
|
||||
vote_index = self._game_engine.parse_vote_command(content)
|
||||
if vote_index is not None:
|
||||
try:
|
||||
voted = await self._game_engine.process_vote(username, vote_index, "twitch")
|
||||
if voted:
|
||||
logger.info(f"Vote recorded: {username} -> option {vote_index + 1}")
|
||||
return # Don't process as regular command
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing vote: {e}")
|
||||
return
|
||||
|
||||
# Forward to game engine for command processing
|
||||
try:
|
||||
await self._game_engine.process_command(username, content)
|
||||
|
||||
445
backend/app/vote_manager.py
Normal file
445
backend/app/vote_manager.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
Vote Manager - Audience Voting System (Phase 9).
|
||||
|
||||
Manages voting sessions for narrative decisions,
|
||||
supporting both Twitch chat commands and Unity client votes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Awaitable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoteOption:
|
||||
"""A voting option."""
|
||||
choice_id: str
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoteSession:
|
||||
"""An active voting session."""
|
||||
vote_id: str
|
||||
options: list[VoteOption]
|
||||
start_ts: float
|
||||
end_ts: float
|
||||
duration_seconds: int = 60 # Store actual duration for this session
|
||||
votes_by_user: dict[str, int] = field(default_factory=dict) # user_id -> choice_index
|
||||
tallies: list[int] = field(default_factory=list) # vote count per option
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoteSnapshot:
|
||||
"""Real-time voting statistics snapshot."""
|
||||
vote_id: str
|
||||
tallies: list[int]
|
||||
percentages: list[float]
|
||||
total_votes: int
|
||||
remaining_seconds: float
|
||||
ends_at: float
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for broadcasting."""
|
||||
return {
|
||||
"vote_id": self.vote_id,
|
||||
"tallies": self.tallies,
|
||||
"percentages": self.percentages,
|
||||
"total_votes": self.total_votes,
|
||||
"remaining_seconds": max(0, self.remaining_seconds),
|
||||
"ends_at": self.ends_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoteResult:
|
||||
"""Final voting result after session ends."""
|
||||
vote_id: str
|
||||
winning_choice_id: str
|
||||
winning_choice_text: str
|
||||
winning_index: int
|
||||
tallies: list[int]
|
||||
percentages: list[float]
|
||||
total_votes: int
|
||||
is_tie: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for broadcasting."""
|
||||
return {
|
||||
"vote_id": self.vote_id,
|
||||
"winning_choice_id": self.winning_choice_id,
|
||||
"winning_choice_text": self.winning_choice_text,
|
||||
"winning_index": self.winning_index,
|
||||
"tallies": self.tallies,
|
||||
"percentages": self.percentages,
|
||||
"total_votes": self.total_votes,
|
||||
"is_tie": self.is_tie,
|
||||
}
|
||||
|
||||
|
||||
# Twitch command patterns
|
||||
VOTE_PATTERN_NUMERIC = re.compile(r"^!([1-9])$") # !1, !2, etc.
|
||||
VOTE_PATTERN_ALPHA = re.compile(r"^!([AaBb])$") # !A, !B, etc.
|
||||
|
||||
|
||||
class VoteManager:
|
||||
"""
|
||||
Manages voting sessions with dual-channel support (Twitch + Unity).
|
||||
|
||||
Features:
|
||||
- Real-time vote counting
|
||||
- Vote changing (users can change their vote)
|
||||
- Automatic session expiration
|
||||
- Periodic snapshot broadcasting
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
duration_seconds: int = 60,
|
||||
broadcast_interval: float = 1.0,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the vote manager.
|
||||
|
||||
Args:
|
||||
duration_seconds: Default voting window duration
|
||||
broadcast_interval: How often to broadcast vote updates (seconds)
|
||||
"""
|
||||
self._duration_seconds = duration_seconds
|
||||
self._broadcast_interval = broadcast_interval
|
||||
self._current: VoteSession | None = None
|
||||
self._broadcast_callback: Callable[[VoteSnapshot], Awaitable[None]] | None = None
|
||||
self._broadcast_task: asyncio.Task | None = None
|
||||
|
||||
@property
|
||||
def is_voting_active(self) -> bool:
|
||||
"""Check if a voting session is currently active."""
|
||||
if not self._current:
|
||||
return False
|
||||
return time.time() < self._current.end_ts
|
||||
|
||||
@property
|
||||
def current_session(self) -> VoteSession | None:
|
||||
"""Get the current voting session."""
|
||||
return self._current
|
||||
|
||||
def set_broadcast_callback(
|
||||
self,
|
||||
callback: Callable[[VoteSnapshot], Awaitable[None]],
|
||||
) -> None:
|
||||
"""
|
||||
Set the callback for broadcasting vote updates.
|
||||
|
||||
Args:
|
||||
callback: Async function that receives VoteSnapshot and broadcasts it
|
||||
"""
|
||||
self._broadcast_callback = callback
|
||||
|
||||
def start_vote(
|
||||
self,
|
||||
options: list[VoteOption],
|
||||
duration_seconds: int | None = None,
|
||||
now: float | None = None,
|
||||
) -> VoteSession:
|
||||
"""
|
||||
Start a new voting session.
|
||||
|
||||
Args:
|
||||
options: List of voting options (minimum 2)
|
||||
duration_seconds: Override default duration
|
||||
now: Override current timestamp (for testing)
|
||||
|
||||
Returns:
|
||||
The created VoteSession
|
||||
"""
|
||||
if len(options) < 2:
|
||||
raise ValueError("Voting requires at least 2 options")
|
||||
|
||||
now = now or time.time()
|
||||
duration = duration_seconds or self._duration_seconds
|
||||
|
||||
session = VoteSession(
|
||||
vote_id=uuid.uuid4().hex,
|
||||
options=options,
|
||||
start_ts=now,
|
||||
end_ts=now + duration,
|
||||
duration_seconds=duration,
|
||||
tallies=[0 for _ in options],
|
||||
)
|
||||
self._current = session
|
||||
|
||||
# Start broadcast loop
|
||||
if self._broadcast_callback:
|
||||
self._start_broadcast_loop()
|
||||
|
||||
logger.info(
|
||||
f"Vote started: {session.vote_id} with {len(options)} options, "
|
||||
f"duration={duration}s"
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
def _start_broadcast_loop(self) -> None:
|
||||
"""Start the periodic broadcast task."""
|
||||
if self._broadcast_task and not self._broadcast_task.done():
|
||||
self._broadcast_task.cancel()
|
||||
|
||||
async def broadcast_loop():
|
||||
try:
|
||||
while self.is_voting_active:
|
||||
snapshot = self.snapshot()
|
||||
if snapshot and self._broadcast_callback:
|
||||
try:
|
||||
await self._broadcast_callback(snapshot)
|
||||
except Exception as e:
|
||||
logger.error(f"Broadcast callback error: {e}")
|
||||
await asyncio.sleep(self._broadcast_interval)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._broadcast_task = asyncio.create_task(broadcast_loop())
|
||||
|
||||
def parse_twitch_message(self, content: str) -> int | None:
|
||||
"""
|
||||
Parse a Twitch chat message for vote commands.
|
||||
|
||||
Supported formats:
|
||||
- !1, !2, !3, etc. (1-indexed, converted to 0-indexed)
|
||||
- !A, !B (converted to 0, 1)
|
||||
|
||||
Args:
|
||||
content: The chat message content
|
||||
|
||||
Returns:
|
||||
Choice index (0-indexed) or None if not a vote command
|
||||
"""
|
||||
text = content.strip()
|
||||
|
||||
# Try numeric pattern first
|
||||
match = VOTE_PATTERN_NUMERIC.match(text)
|
||||
if match:
|
||||
return int(match.group(1)) - 1 # Convert to 0-indexed
|
||||
|
||||
# Try alphabetic pattern
|
||||
match = VOTE_PATTERN_ALPHA.match(text)
|
||||
if match:
|
||||
letter = match.group(1).upper()
|
||||
return ord(letter) - ord('A') # A=0, B=1
|
||||
|
||||
return None
|
||||
|
||||
def cast_vote(
|
||||
self,
|
||||
voter_id: str,
|
||||
choice_index: int,
|
||||
source: str = "twitch",
|
||||
) -> bool:
|
||||
"""
|
||||
Record a vote from a user.
|
||||
|
||||
Users can change their vote - the previous vote is subtracted
|
||||
and the new vote is added.
|
||||
|
||||
Args:
|
||||
voter_id: Unique identifier for the voter
|
||||
choice_index: 0-indexed choice number
|
||||
source: Vote source ("twitch" or "unity")
|
||||
|
||||
Returns:
|
||||
True if vote was recorded, False if invalid or session ended
|
||||
"""
|
||||
if not self._current:
|
||||
logger.debug(f"Vote rejected: no active session (voter={voter_id})")
|
||||
return False
|
||||
|
||||
if time.time() > self._current.end_ts:
|
||||
logger.debug(f"Vote rejected: session ended (voter={voter_id})")
|
||||
return False
|
||||
|
||||
if choice_index < 0 or choice_index >= len(self._current.options):
|
||||
logger.debug(
|
||||
f"Vote rejected: invalid choice {choice_index} "
|
||||
f"(voter={voter_id}, max={len(self._current.options)-1})"
|
||||
)
|
||||
return False
|
||||
|
||||
# Normalize voter ID (Twitch usernames are case-insensitive)
|
||||
normalized_voter_id = voter_id.strip().lower()
|
||||
if not normalized_voter_id:
|
||||
logger.debug("Vote rejected: empty voter id")
|
||||
return False
|
||||
|
||||
# Handle vote change - subtract previous vote
|
||||
previous = self._current.votes_by_user.get(normalized_voter_id)
|
||||
if previous is not None:
|
||||
if previous == choice_index:
|
||||
# Same vote, no change needed
|
||||
return True
|
||||
# Subtract old vote
|
||||
self._current.tallies[previous] = max(
|
||||
0, self._current.tallies[previous] - 1
|
||||
)
|
||||
logger.debug(f"Vote changed: {normalized_voter_id} from {previous} to {choice_index}")
|
||||
|
||||
# Record new vote
|
||||
self._current.votes_by_user[normalized_voter_id] = choice_index
|
||||
self._current.tallies[choice_index] += 1
|
||||
|
||||
logger.debug(
|
||||
f"Vote cast: {voter_id} -> {choice_index} "
|
||||
f"(source={source}, tallies={self._current.tallies})"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def snapshot(self, now: float | None = None) -> VoteSnapshot | None:
|
||||
"""
|
||||
Generate a real-time snapshot of current voting status.
|
||||
|
||||
Args:
|
||||
now: Override current timestamp (for testing)
|
||||
|
||||
Returns:
|
||||
VoteSnapshot or None if no active session
|
||||
"""
|
||||
if not self._current:
|
||||
return None
|
||||
|
||||
now = now or time.time()
|
||||
tallies = list(self._current.tallies)
|
||||
total = sum(tallies)
|
||||
|
||||
# Calculate percentages
|
||||
if total > 0:
|
||||
percentages = [round((t / total) * 100, 1) for t in tallies]
|
||||
else:
|
||||
percentages = [0.0 for _ in tallies]
|
||||
|
||||
return VoteSnapshot(
|
||||
vote_id=self._current.vote_id,
|
||||
tallies=tallies,
|
||||
percentages=percentages,
|
||||
total_votes=total,
|
||||
remaining_seconds=self._current.end_ts - now,
|
||||
ends_at=self._current.end_ts,
|
||||
)
|
||||
|
||||
def maybe_finalize(self, now: float | None = None) -> VoteResult | None:
|
||||
"""
|
||||
Check if voting has ended and finalize results.
|
||||
|
||||
Args:
|
||||
now: Override current timestamp (for testing)
|
||||
|
||||
Returns:
|
||||
VoteResult if voting ended, None if still active
|
||||
"""
|
||||
if not self._current:
|
||||
return None
|
||||
|
||||
now = now or time.time()
|
||||
if now < self._current.end_ts:
|
||||
return None
|
||||
|
||||
# Cancel broadcast task
|
||||
if self._broadcast_task and not self._broadcast_task.done():
|
||||
self._broadcast_task.cancel()
|
||||
|
||||
# Calculate final results
|
||||
tallies = list(self._current.tallies)
|
||||
total = sum(tallies)
|
||||
|
||||
# Calculate percentages
|
||||
if total > 0:
|
||||
percentages = [round((t / total) * 100, 1) for t in tallies]
|
||||
else:
|
||||
percentages = [0.0 for _ in tallies]
|
||||
|
||||
# Find winner
|
||||
if tallies:
|
||||
max_votes = max(tallies)
|
||||
winners = [i for i, t in enumerate(tallies) if t == max_votes]
|
||||
is_tie = len(winners) > 1
|
||||
|
||||
# In case of tie, choose randomly (or could defer to Director)
|
||||
import random
|
||||
winning_index = random.choice(winners) if is_tie else winners[0]
|
||||
else:
|
||||
winning_index = 0
|
||||
is_tie = False
|
||||
|
||||
winning_option = self._current.options[winning_index]
|
||||
|
||||
result = VoteResult(
|
||||
vote_id=self._current.vote_id,
|
||||
winning_choice_id=winning_option.choice_id,
|
||||
winning_choice_text=winning_option.text,
|
||||
winning_index=winning_index,
|
||||
tallies=tallies,
|
||||
percentages=percentages,
|
||||
total_votes=total,
|
||||
is_tie=is_tie,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Vote finalized: {result.vote_id} "
|
||||
f"winner={result.winning_choice_id} ({result.winning_choice_text}) "
|
||||
f"votes={result.tallies} tie={result.is_tie}"
|
||||
)
|
||||
|
||||
# Clear current session
|
||||
self._current = None
|
||||
|
||||
return result
|
||||
|
||||
def cancel_vote(self) -> bool:
|
||||
"""
|
||||
Cancel the current voting session.
|
||||
|
||||
Returns:
|
||||
True if a session was cancelled, False if no active session
|
||||
"""
|
||||
if not self._current:
|
||||
return False
|
||||
|
||||
if self._broadcast_task and not self._broadcast_task.done():
|
||||
self._broadcast_task.cancel()
|
||||
|
||||
vote_id = self._current.vote_id
|
||||
self._current = None
|
||||
|
||||
logger.info(f"Vote cancelled: {vote_id}")
|
||||
return True
|
||||
|
||||
def get_vote_started_data(self) -> dict[str, Any] | None:
|
||||
"""
|
||||
Get data for VOTE_STARTED event.
|
||||
|
||||
Returns:
|
||||
Dictionary with vote session info, or None if no active session
|
||||
"""
|
||||
if not self._current:
|
||||
return None
|
||||
|
||||
return {
|
||||
"vote_id": self._current.vote_id,
|
||||
"choices": [
|
||||
{"choice_id": o.choice_id, "text": o.text}
|
||||
for o in self._current.options
|
||||
],
|
||||
"duration_seconds": self._current.duration_seconds,
|
||||
"ends_at": self._current.end_ts,
|
||||
"source": "director",
|
||||
}
|
||||
|
||||
|
||||
# Global instance
|
||||
vote_manager = VoteManager()
|
||||
@@ -158,6 +158,9 @@ namespace TheIsland.Core
|
||||
network.OnGiftEffect += HandleGiftEffect; // Phase 8
|
||||
network.OnAgentAction += HandleAgentAction; // Phase 13
|
||||
network.OnRandomEvent += HandleRandomEvent; // Phase 17-C
|
||||
network.OnGiveItem += HandleGiveItem; // Phase 23
|
||||
network.OnGroupActivity += HandleGroupActivity; // Phase 24
|
||||
network.OnVFXEvent += HandleVFXEvent; // Phase 8
|
||||
}
|
||||
|
||||
private void UnsubscribeFromNetworkEvents()
|
||||
@@ -186,6 +189,9 @@ namespace TheIsland.Core
|
||||
network.OnSocialInteraction -= HandleSocialInteraction;
|
||||
network.OnGiftEffect -= HandleGiftEffect; // Phase 8
|
||||
network.OnRandomEvent -= HandleRandomEvent; // Phase 17-C
|
||||
network.OnGiveItem -= HandleGiveItem; // Phase 23
|
||||
network.OnGroupActivity -= HandleGroupActivity; // Phase 24
|
||||
network.OnVFXEvent -= HandleVFXEvent; // Phase 8
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -447,10 +453,84 @@ namespace TheIsland.Core
|
||||
_playerGold = data.user_gold;
|
||||
UpdateGoldDisplay();
|
||||
}
|
||||
|
||||
ShowNotification(data.message);
|
||||
}
|
||||
|
||||
private void HandleGiveItem(GiveItemEventData data)
|
||||
{
|
||||
Debug.Log($"[GameManager] Give Item: {data.message}");
|
||||
ShowNotification(data.message);
|
||||
|
||||
if (_agentVisuals.TryGetValue(data.from_id, out AgentVisual fromVisual) &&
|
||||
_agentVisuals.TryGetValue(data.to_id, out AgentVisual toVisual))
|
||||
{
|
||||
// Trigger Visual Effect (Phase 23)
|
||||
// We assume AgentVisual has DoGiveItem method
|
||||
fromVisual.DoGiveItem(toVisual.transform, data.item_type);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleGroupActivity(GroupActivityEventData data)
|
||||
{
|
||||
Debug.Log($"[GameManager] Group Activity: {data.activity_type} with {data.storyteller_name}");
|
||||
|
||||
if (data.activity_type == "storytelling")
|
||||
{
|
||||
ShowNotification($"🔥 {data.storyteller_name} tells a story about {data.topic}...");
|
||||
|
||||
// Storyteller Visuals
|
||||
if (_agentVisuals.TryGetValue(data.storyteller_id, out AgentVisual storyteller))
|
||||
{
|
||||
storyteller.ShowSpeech(data.content, 8f); // Long duration for story
|
||||
storyteller.DoStorytelling(); // Animation trigger
|
||||
}
|
||||
|
||||
// Listener Visuals
|
||||
if (data.listener_ids != null)
|
||||
{
|
||||
foreach (int listenerId in data.listener_ids)
|
||||
{
|
||||
if (_agentVisuals.TryGetValue(listenerId, out AgentVisual listener))
|
||||
{
|
||||
// Listeners face the storyteller and show interest
|
||||
if (storyteller != null)
|
||||
{
|
||||
listener.DoListen(storyteller.transform);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleVFXEvent(VFXEventData data)
|
||||
{
|
||||
Debug.Log($"[GameManager] VFX: {data.effect}");
|
||||
if (!string.IsNullOrEmpty(data.message))
|
||||
{
|
||||
ShowNotification(data.message);
|
||||
}
|
||||
|
||||
Vector3 targetPos = new Vector3(0, 2, 0); // Default air center
|
||||
|
||||
if (data.target_id > 0)
|
||||
{
|
||||
if (_agentVisuals.TryGetValue(data.target_id, out AgentVisual visual))
|
||||
{
|
||||
targetPos = visual.transform.position;
|
||||
}
|
||||
else if (_agentUIs.TryGetValue(data.target_id, out AgentUI ui))
|
||||
{
|
||||
targetPos = ui.transform.position;
|
||||
}
|
||||
}
|
||||
|
||||
if (VFXManager.Instance != null)
|
||||
{
|
||||
VFXManager.Instance.PlayEffect(data.effect, targetPos);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleTalk(TalkEventData data)
|
||||
{
|
||||
Debug.Log($"[GameManager] Talk event: {data.agent_name} responds about '{data.topic}'");
|
||||
@@ -553,7 +633,22 @@ namespace TheIsland.Core
|
||||
// Find agent and command movement
|
||||
if (_agentVisuals.TryGetValue(data.agent_id, out AgentVisual agentVisual))
|
||||
{
|
||||
agentVisual.MoveTo(targetPos);
|
||||
// Phase 21-C: Handle Follow/Target Logic
|
||||
if (data.action_type == "Follow" && !string.IsNullOrEmpty(data.target_name))
|
||||
{
|
||||
int targetId = GetAgentIdByName(data.target_name);
|
||||
if (targetId >= 0 && _agentVisuals.TryGetValue(targetId, out AgentVisual targetVisual))
|
||||
{
|
||||
agentVisual.SetFollowTarget(targetVisual.transform);
|
||||
Debug.Log($"[GameManager] {data.agent_name} is now following {data.target_name}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clear follow target for non-follow actions
|
||||
agentVisual.SetFollowTarget(null);
|
||||
agentVisual.MoveTo(targetPos);
|
||||
}
|
||||
|
||||
// Optional: Show thought bubble or speech
|
||||
if (!string.IsNullOrEmpty(data.dialogue))
|
||||
|
||||
@@ -353,6 +353,24 @@ namespace TheIsland.Models
|
||||
|
||||
// Random Events (Phase 17-C)
|
||||
public const string RANDOM_EVENT = "random_event";
|
||||
|
||||
// Economy (Phase 23)
|
||||
public const string GIVE_ITEM = "give_item";
|
||||
|
||||
// Group Activities (Phase 24)
|
||||
public const string GROUP_ACTIVITY = "group_activity";
|
||||
|
||||
// Phase 8: VFX
|
||||
public const string VFX_EVENT = "vfx_event";
|
||||
|
||||
// AI Director & Narrative Voting (Phase 9)
|
||||
public const string MODE_CHANGE = "mode_change";
|
||||
public const string NARRATIVE_PLOT = "narrative_plot";
|
||||
public const string VOTE_STARTED = "vote_started";
|
||||
public const string VOTE_UPDATE = "vote_update";
|
||||
public const string VOTE_ENDED = "vote_ended";
|
||||
public const string VOTE_RESULT = "vote_result";
|
||||
public const string RESOLUTION_APPLIED = "resolution_applied";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -403,4 +421,145 @@ namespace TheIsland.Models
|
||||
public string message;
|
||||
public string agent_name; // Optional: affected agent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Give item event data (Phase 23).
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class GiveItemEventData
|
||||
{
|
||||
public int from_id;
|
||||
public int to_id;
|
||||
public string item_type; // "herb", "food", "medicine"
|
||||
public string message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Group Activity event data (Phase 24).
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class GroupActivityEventData
|
||||
{
|
||||
public string activity_type; // "storytelling"
|
||||
public int storyteller_id;
|
||||
public string storyteller_name;
|
||||
public List<int> listener_ids;
|
||||
public string content; // The story text
|
||||
public string topic;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VFX event data (Phase 8).
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class VFXEventData
|
||||
{
|
||||
public string effect; // "gold_rain", "heart", "food"
|
||||
public int target_id; // Optional: if -1 or 0, might mean global or specific position logic
|
||||
public string message;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// AI Director & Narrative Voting (Phase 9)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Mode change event data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class ModeChangeData
|
||||
{
|
||||
public string mode; // "simulation", "narrative", "voting", "resolution"
|
||||
public string old_mode;
|
||||
public string message;
|
||||
public double ends_at; // Timestamp when this mode ends (for voting)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Choice option in a plot point.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class PlotChoiceData
|
||||
{
|
||||
public string choice_id;
|
||||
public string text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Narrative plot event data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class NarrativePlotData
|
||||
{
|
||||
public string plot_id;
|
||||
public string title;
|
||||
public string description;
|
||||
public List<PlotChoiceData> choices;
|
||||
public int ttl_seconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vote started event data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class VoteStartedData
|
||||
{
|
||||
public string vote_id;
|
||||
public List<PlotChoiceData> choices;
|
||||
public int duration_seconds;
|
||||
public double ends_at;
|
||||
public string source;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Real-time vote update data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class VoteUpdateData
|
||||
{
|
||||
public string vote_id;
|
||||
public List<int> tallies;
|
||||
public List<float> percentages;
|
||||
public int total_votes;
|
||||
public float remaining_seconds;
|
||||
public double ends_at;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vote ended event data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class VoteEndedData
|
||||
{
|
||||
public string vote_id;
|
||||
public int total_votes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Final voting result data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class VoteResultData
|
||||
{
|
||||
public string vote_id;
|
||||
public string winning_choice_id;
|
||||
public string winning_choice_text;
|
||||
public int winning_index;
|
||||
public List<int> tallies;
|
||||
public List<float> percentages;
|
||||
public int total_votes;
|
||||
public bool is_tie;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolution applied event data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class ResolutionAppliedData
|
||||
{
|
||||
public string plot_id;
|
||||
public string choice_id;
|
||||
public string message;
|
||||
public string effects_json;
|
||||
}
|
||||
}
|
||||
|
||||
588
unity-client/Assets/Scripts/NarrativeUI.cs
Normal file
588
unity-client/Assets/Scripts/NarrativeUI.cs
Normal file
@@ -0,0 +1,588 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using TheIsland.Models;
|
||||
using TheIsland.Network;
|
||||
|
||||
namespace TheIsland.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Cinematic narrative UI overlay for AI Director events and voting.
|
||||
/// Handles plot cards, voting bars, and resolution displays.
|
||||
/// </summary>
|
||||
public class NarrativeUI : MonoBehaviour
|
||||
{
|
||||
#region Singleton
|
||||
private static NarrativeUI _instance;
|
||||
public static NarrativeUI Instance => _instance;
|
||||
#endregion
|
||||
|
||||
#region UI References
|
||||
[Header("Main Panel")]
|
||||
[SerializeField] private CanvasGroup mainPanel;
|
||||
[SerializeField] private Image backgroundOverlay;
|
||||
|
||||
[Header("Event Card")]
|
||||
[SerializeField] private RectTransform eventCard;
|
||||
[SerializeField] private TextMeshProUGUI titleText;
|
||||
[SerializeField] private TextMeshProUGUI descriptionText;
|
||||
|
||||
[Header("Voting Panel")]
|
||||
[SerializeField] private RectTransform votingPanel;
|
||||
[SerializeField] private TextMeshProUGUI timerText;
|
||||
[SerializeField] private TextMeshProUGUI totalVotesText;
|
||||
|
||||
[Header("Choice A")]
|
||||
[SerializeField] private RectTransform choiceAContainer;
|
||||
[SerializeField] private TextMeshProUGUI choiceAText;
|
||||
[SerializeField] private Image choiceABar;
|
||||
[SerializeField] private TextMeshProUGUI choiceAPercentText;
|
||||
|
||||
[Header("Choice B")]
|
||||
[SerializeField] private RectTransform choiceBContainer;
|
||||
[SerializeField] private TextMeshProUGUI choiceBText;
|
||||
[SerializeField] private Image choiceBBar;
|
||||
[SerializeField] private TextMeshProUGUI choiceBPercentText;
|
||||
|
||||
[Header("Result Panel")]
|
||||
[SerializeField] private RectTransform resultPanel;
|
||||
[SerializeField] private TextMeshProUGUI resultTitleText;
|
||||
[SerializeField] private TextMeshProUGUI resultMessageText;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[SerializeField] private float fadeInDuration = 0.5f;
|
||||
[SerializeField] private float fadeOutDuration = 0.3f;
|
||||
[SerializeField] private float cardSlideDistance = 100f;
|
||||
[SerializeField] private float barAnimationSpeed = 5f;
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
private bool isActive = false;
|
||||
private string currentPlotId;
|
||||
private float targetChoiceAPercent = 0f;
|
||||
private float targetChoiceBPercent = 0f;
|
||||
private float currentChoiceAPercent = 0f;
|
||||
private float currentChoiceBPercent = 0f;
|
||||
private double votingEndsAt = 0;
|
||||
private Coroutine timerCoroutine;
|
||||
private bool isSubscribed = false;
|
||||
private Coroutine subscribeCoroutine;
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
private void Awake()
|
||||
{
|
||||
if (_instance != null && _instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
_instance = this;
|
||||
|
||||
// Initialize UI state
|
||||
if (mainPanel != null) mainPanel.alpha = 0;
|
||||
if (mainPanel != null) mainPanel.blocksRaycasts = false;
|
||||
HideAllPanels();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// Start coroutine to subscribe when NetworkManager is ready
|
||||
subscribeCoroutine = StartCoroutine(SubscribeWhenReady());
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
// Stop subscribe coroutine if running
|
||||
if (subscribeCoroutine != null)
|
||||
{
|
||||
StopCoroutine(subscribeCoroutine);
|
||||
subscribeCoroutine = null;
|
||||
}
|
||||
|
||||
// Unsubscribe from network events
|
||||
UnsubscribeFromNetwork();
|
||||
}
|
||||
|
||||
private IEnumerator SubscribeWhenReady()
|
||||
{
|
||||
// Wait until NetworkManager is available
|
||||
while (NetworkManager.Instance == null)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
SubscribeToNetwork();
|
||||
}
|
||||
|
||||
private void SubscribeToNetwork()
|
||||
{
|
||||
if (isSubscribed) return;
|
||||
|
||||
var network = NetworkManager.Instance;
|
||||
if (network == null) return;
|
||||
|
||||
network.OnModeChange += HandleModeChange;
|
||||
network.OnNarrativePlot += HandleNarrativePlot;
|
||||
network.OnVoteStarted += HandleVoteStarted;
|
||||
network.OnVoteUpdate += HandleVoteUpdate;
|
||||
network.OnVoteResult += HandleVoteResult;
|
||||
network.OnResolutionApplied += HandleResolutionApplied;
|
||||
isSubscribed = true;
|
||||
}
|
||||
|
||||
private void UnsubscribeFromNetwork()
|
||||
{
|
||||
if (!isSubscribed) return;
|
||||
|
||||
var network = NetworkManager.Instance;
|
||||
if (network == null)
|
||||
{
|
||||
isSubscribed = false;
|
||||
return;
|
||||
}
|
||||
|
||||
network.OnModeChange -= HandleModeChange;
|
||||
network.OnNarrativePlot -= HandleNarrativePlot;
|
||||
network.OnVoteStarted -= HandleVoteStarted;
|
||||
network.OnVoteUpdate -= HandleVoteUpdate;
|
||||
network.OnVoteResult -= HandleVoteResult;
|
||||
network.OnResolutionApplied -= HandleResolutionApplied;
|
||||
isSubscribed = false;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// Smoothly animate voting bars
|
||||
if (isActive && votingPanel != null && votingPanel.gameObject.activeSelf)
|
||||
{
|
||||
AnimateVotingBars();
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
private void HandleModeChange(ModeChangeData data)
|
||||
{
|
||||
Debug.Log($"[NarrativeUI] Mode changed: {data.old_mode} -> {data.mode}");
|
||||
|
||||
switch (data.mode)
|
||||
{
|
||||
case "simulation":
|
||||
// Fade out UI when returning to simulation
|
||||
if (isActive)
|
||||
{
|
||||
StartCoroutine(FadeOutUI());
|
||||
}
|
||||
break;
|
||||
|
||||
case "narrative":
|
||||
case "voting":
|
||||
// Ensure UI is visible
|
||||
if (!isActive)
|
||||
{
|
||||
StartCoroutine(FadeInUI());
|
||||
}
|
||||
break;
|
||||
|
||||
case "resolution":
|
||||
// Keep UI visible for resolution display
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleNarrativePlot(NarrativePlotData data)
|
||||
{
|
||||
Debug.Log($"[NarrativeUI] Narrative plot: {data.title}");
|
||||
currentPlotId = data.plot_id;
|
||||
|
||||
// Show event card
|
||||
ShowEventCard(data.title, data.description);
|
||||
|
||||
// Prepare voting choices
|
||||
if (data.choices != null && data.choices.Count >= 2)
|
||||
{
|
||||
SetupVotingChoices(data.choices[0].text, data.choices[1].text);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleVoteStarted(VoteStartedData data)
|
||||
{
|
||||
Debug.Log($"[NarrativeUI] Vote started: {data.vote_id}");
|
||||
|
||||
votingEndsAt = data.ends_at;
|
||||
|
||||
// Setup choices if not already done
|
||||
if (data.choices != null && data.choices.Count >= 2)
|
||||
{
|
||||
SetupVotingChoices(data.choices[0].text, data.choices[1].text);
|
||||
}
|
||||
|
||||
// Show voting panel
|
||||
ShowVotingPanel();
|
||||
|
||||
// Start countdown timer
|
||||
if (timerCoroutine != null) StopCoroutine(timerCoroutine);
|
||||
timerCoroutine = StartCoroutine(UpdateTimer());
|
||||
}
|
||||
|
||||
private void HandleVoteUpdate(VoteUpdateData data)
|
||||
{
|
||||
// Update target percentages for smooth animation
|
||||
if (data.percentages != null && data.percentages.Count >= 2)
|
||||
{
|
||||
targetChoiceAPercent = data.percentages[0];
|
||||
targetChoiceBPercent = data.percentages[1];
|
||||
}
|
||||
|
||||
// Sync timer with server's remaining_seconds to avoid clock drift
|
||||
if (data.remaining_seconds > 0)
|
||||
{
|
||||
double now = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
votingEndsAt = now + data.remaining_seconds;
|
||||
}
|
||||
|
||||
// Update total votes display
|
||||
if (totalVotesText != null)
|
||||
{
|
||||
totalVotesText.text = $"{data.total_votes} votes";
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleVoteResult(VoteResultData data)
|
||||
{
|
||||
Debug.Log($"[NarrativeUI] Vote result: {data.winning_choice_text}");
|
||||
|
||||
// Stop timer
|
||||
if (timerCoroutine != null)
|
||||
{
|
||||
StopCoroutine(timerCoroutine);
|
||||
timerCoroutine = null;
|
||||
}
|
||||
|
||||
// Flash winning choice
|
||||
StartCoroutine(FlashWinningChoice(data.winning_index));
|
||||
|
||||
// Show result briefly
|
||||
ShowResult($"The Audience Has Spoken!", data.winning_choice_text);
|
||||
}
|
||||
|
||||
private void HandleResolutionApplied(ResolutionAppliedData data)
|
||||
{
|
||||
Debug.Log($"[NarrativeUI] Resolution: {data.message}");
|
||||
|
||||
// Update result display with full resolution message
|
||||
ShowResult("Consequence", data.message);
|
||||
|
||||
// Auto-hide after delay
|
||||
StartCoroutine(HideAfterDelay(5f));
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region UI Control Methods
|
||||
private void HideAllPanels()
|
||||
{
|
||||
if (eventCard != null) eventCard.gameObject.SetActive(false);
|
||||
if (votingPanel != null) votingPanel.gameObject.SetActive(false);
|
||||
if (resultPanel != null) resultPanel.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
private void ShowEventCard(string title, string description)
|
||||
{
|
||||
if (eventCard == null) return;
|
||||
|
||||
// Set content
|
||||
if (titleText != null) titleText.text = title;
|
||||
if (descriptionText != null) descriptionText.text = description;
|
||||
|
||||
// Show card with animation
|
||||
eventCard.gameObject.SetActive(true);
|
||||
StartCoroutine(SlideInCard(eventCard));
|
||||
}
|
||||
|
||||
private void SetupVotingChoices(string choiceA, string choiceB)
|
||||
{
|
||||
if (choiceAText != null) choiceAText.text = $"!1 {choiceA}";
|
||||
if (choiceBText != null) choiceBText.text = $"!2 {choiceB}";
|
||||
|
||||
// Reset percentages
|
||||
targetChoiceAPercent = 50f;
|
||||
targetChoiceBPercent = 50f;
|
||||
currentChoiceAPercent = 50f;
|
||||
currentChoiceBPercent = 50f;
|
||||
|
||||
UpdateVotingBarsImmediate();
|
||||
}
|
||||
|
||||
private void ShowVotingPanel()
|
||||
{
|
||||
if (votingPanel == null) return;
|
||||
|
||||
votingPanel.gameObject.SetActive(true);
|
||||
StartCoroutine(SlideInCard(votingPanel));
|
||||
}
|
||||
|
||||
private void ShowResult(string title, string message)
|
||||
{
|
||||
if (resultPanel == null) return;
|
||||
|
||||
// Hide other panels
|
||||
if (eventCard != null) eventCard.gameObject.SetActive(false);
|
||||
if (votingPanel != null) votingPanel.gameObject.SetActive(false);
|
||||
|
||||
// Set content
|
||||
if (resultTitleText != null) resultTitleText.text = title;
|
||||
if (resultMessageText != null) resultMessageText.text = message;
|
||||
|
||||
// Show result
|
||||
resultPanel.gameObject.SetActive(true);
|
||||
StartCoroutine(SlideInCard(resultPanel));
|
||||
}
|
||||
|
||||
private void AnimateVotingBars()
|
||||
{
|
||||
// Smoothly interpolate bar widths
|
||||
currentChoiceAPercent = Mathf.Lerp(
|
||||
currentChoiceAPercent,
|
||||
targetChoiceAPercent,
|
||||
Time.deltaTime * barAnimationSpeed
|
||||
);
|
||||
currentChoiceBPercent = Mathf.Lerp(
|
||||
currentChoiceBPercent,
|
||||
targetChoiceBPercent,
|
||||
Time.deltaTime * barAnimationSpeed
|
||||
);
|
||||
|
||||
UpdateVotingBarsImmediate();
|
||||
}
|
||||
|
||||
private void UpdateVotingBarsImmediate()
|
||||
{
|
||||
// Update bar fill amounts (assuming horizontal fill)
|
||||
if (choiceABar != null)
|
||||
{
|
||||
choiceABar.fillAmount = currentChoiceAPercent / 100f;
|
||||
}
|
||||
if (choiceBBar != null)
|
||||
{
|
||||
choiceBBar.fillAmount = currentChoiceBPercent / 100f;
|
||||
}
|
||||
|
||||
// Update percentage texts
|
||||
if (choiceAPercentText != null)
|
||||
{
|
||||
choiceAPercentText.text = $"{Mathf.RoundToInt(currentChoiceAPercent)}%";
|
||||
}
|
||||
if (choiceBPercentText != null)
|
||||
{
|
||||
choiceBPercentText.text = $"{Mathf.RoundToInt(currentChoiceBPercent)}%";
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Coroutines
|
||||
private IEnumerator FadeInUI()
|
||||
{
|
||||
isActive = true;
|
||||
if (mainPanel == null) yield break;
|
||||
|
||||
mainPanel.blocksRaycasts = true;
|
||||
|
||||
float elapsed = 0f;
|
||||
while (elapsed < fadeInDuration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
mainPanel.alpha = Mathf.Lerp(0f, 1f, elapsed / fadeInDuration);
|
||||
yield return null;
|
||||
}
|
||||
mainPanel.alpha = 1f;
|
||||
|
||||
// Darken background
|
||||
if (backgroundOverlay != null)
|
||||
{
|
||||
Color c = backgroundOverlay.color;
|
||||
c.a = 0.6f;
|
||||
backgroundOverlay.color = c;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator FadeOutUI()
|
||||
{
|
||||
if (mainPanel == null)
|
||||
{
|
||||
isActive = false;
|
||||
yield break;
|
||||
}
|
||||
|
||||
float elapsed = 0f;
|
||||
float startAlpha = mainPanel.alpha;
|
||||
|
||||
while (elapsed < fadeOutDuration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
mainPanel.alpha = Mathf.Lerp(startAlpha, 0f, elapsed / fadeOutDuration);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
mainPanel.alpha = 0f;
|
||||
mainPanel.blocksRaycasts = false;
|
||||
isActive = false;
|
||||
|
||||
HideAllPanels();
|
||||
}
|
||||
|
||||
private IEnumerator SlideInCard(RectTransform card)
|
||||
{
|
||||
if (card == null) yield break;
|
||||
|
||||
Vector2 startPos = card.anchoredPosition;
|
||||
Vector2 targetPos = startPos;
|
||||
startPos.y -= cardSlideDistance;
|
||||
|
||||
card.anchoredPosition = startPos;
|
||||
|
||||
float elapsed = 0f;
|
||||
while (elapsed < fadeInDuration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = elapsed / fadeInDuration;
|
||||
// Ease out cubic
|
||||
t = 1f - Mathf.Pow(1f - t, 3f);
|
||||
card.anchoredPosition = Vector2.Lerp(startPos, targetPos, t);
|
||||
yield return null;
|
||||
}
|
||||
card.anchoredPosition = targetPos;
|
||||
}
|
||||
|
||||
private IEnumerator UpdateTimer()
|
||||
{
|
||||
while (votingEndsAt > 0)
|
||||
{
|
||||
double now = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
double remaining = votingEndsAt - now;
|
||||
|
||||
if (remaining <= 0)
|
||||
{
|
||||
if (timerText != null) timerText.text = "0s";
|
||||
break;
|
||||
}
|
||||
|
||||
if (timerText != null)
|
||||
{
|
||||
timerText.text = $"{Mathf.CeilToInt((float)remaining)}s";
|
||||
}
|
||||
|
||||
yield return new WaitForSeconds(0.1f);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator FlashWinningChoice(int winningIndex)
|
||||
{
|
||||
// Flash the winning choice bar
|
||||
Image winningBar = winningIndex == 0 ? choiceABar : choiceBBar;
|
||||
if (winningBar == null) yield break;
|
||||
|
||||
Color originalColor = winningBar.color;
|
||||
Color flashColor = Color.yellow;
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
winningBar.color = flashColor;
|
||||
yield return new WaitForSeconds(0.15f);
|
||||
winningBar.color = originalColor;
|
||||
yield return new WaitForSeconds(0.15f);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator HideAfterDelay(float delay)
|
||||
{
|
||||
yield return new WaitForSeconds(delay);
|
||||
|
||||
// The mode will change to simulation, which will trigger fade out
|
||||
// But we can also force it here as a fallback
|
||||
if (isActive)
|
||||
{
|
||||
StartCoroutine(FadeOutUI());
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Public Methods
|
||||
/// <summary>
|
||||
/// Force show the narrative UI (for testing).
|
||||
/// </summary>
|
||||
public void ForceShow()
|
||||
{
|
||||
StartCoroutine(FadeInUI());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force hide the narrative UI.
|
||||
/// </summary>
|
||||
public void ForceHide()
|
||||
{
|
||||
StartCoroutine(FadeOutUI());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test the UI with sample data.
|
||||
/// </summary>
|
||||
[ContextMenu("Test Narrative UI")]
|
||||
public void TestUI()
|
||||
{
|
||||
// Create test data
|
||||
var plotData = new NarrativePlotData
|
||||
{
|
||||
plot_id = "test_001",
|
||||
title = "Mysterious Footprints",
|
||||
description = "Strange footprints appear on the beach. Someone has been watching...",
|
||||
choices = new List<PlotChoiceData>
|
||||
{
|
||||
new PlotChoiceData { choice_id = "investigate", text = "Follow the tracks" },
|
||||
new PlotChoiceData { choice_id = "fortify", text = "Strengthen defenses" }
|
||||
},
|
||||
ttl_seconds = 60
|
||||
};
|
||||
|
||||
// Simulate events
|
||||
HandleModeChange(new ModeChangeData { mode = "narrative", old_mode = "simulation", message = "Director intervenes..." });
|
||||
HandleNarrativePlot(plotData);
|
||||
|
||||
// Simulate vote start after delay
|
||||
StartCoroutine(SimulateVoting());
|
||||
}
|
||||
|
||||
private IEnumerator SimulateVoting()
|
||||
{
|
||||
yield return new WaitForSeconds(2f);
|
||||
|
||||
HandleVoteStarted(new VoteStartedData
|
||||
{
|
||||
vote_id = "test_vote",
|
||||
duration_seconds = 30,
|
||||
ends_at = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 30,
|
||||
choices = new List<PlotChoiceData>
|
||||
{
|
||||
new PlotChoiceData { choice_id = "investigate", text = "Follow the tracks" },
|
||||
new PlotChoiceData { choice_id = "fortify", text = "Strengthen defenses" }
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate vote updates
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
yield return new WaitForSeconds(1f);
|
||||
HandleVoteUpdate(new VoteUpdateData
|
||||
{
|
||||
vote_id = "test_vote",
|
||||
tallies = new List<int> { Random.Range(10, 50), Random.Range(10, 50) },
|
||||
percentages = new List<float> { Random.Range(30f, 70f), Random.Range(30f, 70f) },
|
||||
total_votes = Random.Range(20, 100),
|
||||
remaining_seconds = 30 - i
|
||||
});
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
2
unity-client/Assets/Scripts/NarrativeUI.cs.meta
Normal file
2
unity-client/Assets/Scripts/NarrativeUI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7748b2f53bcb247f8a0e6707b1ecb1ce
|
||||
@@ -72,6 +72,18 @@ namespace TheIsland.Network
|
||||
public event Action<CraftEventData> OnCraft; // Phase 16: Crafting
|
||||
public event Action<UseItemEventData> OnUseItem; // Phase 16: Using items
|
||||
public event Action<RandomEventData> OnRandomEvent; // Phase 17-C: Random Events
|
||||
public event Action<GiveItemEventData> OnGiveItem; // Phase 23: Item Exchange
|
||||
public event Action<GroupActivityEventData> OnGroupActivity; // Phase 24: Group Activities
|
||||
public event Action<VFXEventData> OnVFXEvent; // Phase 8: VFX
|
||||
|
||||
// AI Director & Narrative Voting (Phase 9)
|
||||
public event Action<ModeChangeData> OnModeChange;
|
||||
public event Action<NarrativePlotData> OnNarrativePlot;
|
||||
public event Action<VoteStartedData> OnVoteStarted;
|
||||
public event Action<VoteUpdateData> OnVoteUpdate;
|
||||
public event Action<VoteEndedData> OnVoteEnded;
|
||||
public event Action<VoteResultData> OnVoteResult;
|
||||
public event Action<ResolutionAppliedData> OnResolutionApplied;
|
||||
#endregion
|
||||
|
||||
#region Private Fields
|
||||
@@ -379,6 +391,57 @@ namespace TheIsland.Network
|
||||
Debug.Log($"[Random Event] {randomEventData.event_type}: {randomEventData.message}");
|
||||
break;
|
||||
|
||||
case EventTypes.GIVE_ITEM:
|
||||
var giveData = JsonUtility.FromJson<GiveItemEventData>(dataJson);
|
||||
OnGiveItem?.Invoke(giveData);
|
||||
break;
|
||||
|
||||
case EventTypes.GROUP_ACTIVITY:
|
||||
var groupData = JsonUtility.FromJson<GroupActivityEventData>(dataJson);
|
||||
OnGroupActivity?.Invoke(groupData);
|
||||
break;
|
||||
|
||||
case EventTypes.VFX_EVENT:
|
||||
var vfxData = JsonUtility.FromJson<VFXEventData>(dataJson);
|
||||
OnVFXEvent?.Invoke(vfxData);
|
||||
break;
|
||||
|
||||
// AI Director & Narrative Voting (Phase 9)
|
||||
case EventTypes.MODE_CHANGE:
|
||||
var modeData = JsonUtility.FromJson<ModeChangeData>(dataJson);
|
||||
OnModeChange?.Invoke(modeData);
|
||||
break;
|
||||
|
||||
case EventTypes.NARRATIVE_PLOT:
|
||||
var plotData = JsonUtility.FromJson<NarrativePlotData>(dataJson);
|
||||
OnNarrativePlot?.Invoke(plotData);
|
||||
break;
|
||||
|
||||
case EventTypes.VOTE_STARTED:
|
||||
var voteStarted = JsonUtility.FromJson<VoteStartedData>(dataJson);
|
||||
OnVoteStarted?.Invoke(voteStarted);
|
||||
break;
|
||||
|
||||
case EventTypes.VOTE_UPDATE:
|
||||
var voteUpdate = JsonUtility.FromJson<VoteUpdateData>(dataJson);
|
||||
OnVoteUpdate?.Invoke(voteUpdate);
|
||||
break;
|
||||
|
||||
case EventTypes.VOTE_ENDED:
|
||||
var voteEnded = JsonUtility.FromJson<VoteEndedData>(dataJson);
|
||||
OnVoteEnded?.Invoke(voteEnded);
|
||||
break;
|
||||
|
||||
case EventTypes.VOTE_RESULT:
|
||||
var voteResult = JsonUtility.FromJson<VoteResultData>(dataJson);
|
||||
OnVoteResult?.Invoke(voteResult);
|
||||
break;
|
||||
|
||||
case EventTypes.RESOLUTION_APPLIED:
|
||||
var resolution = JsonUtility.FromJson<ResolutionAppliedData>(dataJson);
|
||||
OnResolutionApplied?.Invoke(resolution);
|
||||
break;
|
||||
|
||||
default:
|
||||
Debug.Log($"[NetworkManager] Unhandled event type: {baseMessage.event_type}");
|
||||
break;
|
||||
|
||||
@@ -44,6 +44,11 @@ namespace TheIsland.Visual
|
||||
[SerializeField] private int dustParticleCount = 8;
|
||||
[SerializeField] private float dustDuration = 0.5f;
|
||||
|
||||
[Header("Food Poof Settings")]
|
||||
[SerializeField] private Color foodColor = new Color(1f, 1f, 1f, 0.7f); // White smoke
|
||||
[SerializeField] private int foodParticleCount = 15;
|
||||
[SerializeField] private float foodDuration = 1.0f;
|
||||
|
||||
[Header("General Settings")]
|
||||
[SerializeField] private float effectScale = 1f;
|
||||
#endregion
|
||||
@@ -96,6 +101,17 @@ namespace TheIsland.Visual
|
||||
Destroy(ps.gameObject, dustDuration + 0.2f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Play food poof effect (white smoke).
|
||||
/// Used for feeding.
|
||||
/// </summary>
|
||||
public void PlayFoodPoof(Vector3 position)
|
||||
{
|
||||
var ps = CreateFoodPoofSystem(position);
|
||||
ps.Play();
|
||||
Destroy(ps.gameObject, foodDuration + 0.5f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Play an effect by type name.
|
||||
/// </summary>
|
||||
@@ -114,6 +130,10 @@ namespace TheIsland.Visual
|
||||
case "subscription":
|
||||
PlayHeartExplosion(position);
|
||||
break;
|
||||
case "food":
|
||||
case "feed":
|
||||
PlayFoodPoof(position);
|
||||
break;
|
||||
default:
|
||||
// Default to gold rain
|
||||
PlayGoldRain(position);
|
||||
@@ -130,9 +150,12 @@ namespace TheIsland.Visual
|
||||
{
|
||||
GameObject go = new GameObject("GoldRain_VFX");
|
||||
go.transform.position = position + Vector3.up * 3f; // Start above
|
||||
|
||||
|
||||
ParticleSystem ps = go.AddComponent<ParticleSystem>();
|
||||
// Stop immediately to prevent "duration while playing" warning
|
||||
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
||||
var main = ps.main;
|
||||
main.playOnAwake = false;
|
||||
main.loop = false;
|
||||
main.duration = goldDuration;
|
||||
main.startLifetime = 1.5f;
|
||||
@@ -203,9 +226,12 @@ namespace TheIsland.Visual
|
||||
{
|
||||
GameObject go = new GameObject("HeartExplosion_VFX");
|
||||
go.transform.position = position + Vector3.up * 1.5f;
|
||||
|
||||
|
||||
ParticleSystem ps = go.AddComponent<ParticleSystem>();
|
||||
// Stop immediately to prevent "duration while playing" warning
|
||||
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
||||
var main = ps.main;
|
||||
main.playOnAwake = false;
|
||||
main.loop = false;
|
||||
main.duration = heartDuration;
|
||||
main.startLifetime = 1.2f;
|
||||
@@ -280,7 +306,10 @@ namespace TheIsland.Visual
|
||||
go.transform.position = position;
|
||||
|
||||
ParticleSystem ps = go.AddComponent<ParticleSystem>();
|
||||
// Stop immediately to prevent "duration while playing" warning
|
||||
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
||||
var main = ps.main;
|
||||
main.playOnAwake = false;
|
||||
main.loop = false;
|
||||
main.duration = dustDuration;
|
||||
main.startLifetime = 0.4f;
|
||||
@@ -336,6 +365,61 @@ namespace TheIsland.Visual
|
||||
return ps;
|
||||
}
|
||||
|
||||
private ParticleSystem CreateFoodPoofSystem(Vector3 position)
|
||||
{
|
||||
GameObject go = new GameObject("FoodPoof_VFX");
|
||||
go.transform.position = position + Vector3.up * 1.5f;
|
||||
|
||||
ParticleSystem ps = go.AddComponent<ParticleSystem>();
|
||||
// Stop immediately to prevent "duration while playing" warning
|
||||
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
||||
var main = ps.main;
|
||||
main.playOnAwake = false;
|
||||
main.loop = false;
|
||||
main.duration = foodDuration;
|
||||
main.startLifetime = 0.8f;
|
||||
main.startSpeed = new ParticleSystem.MinMaxCurve(0.5f, 2f);
|
||||
main.startSize = new ParticleSystem.MinMaxCurve(0.3f * effectScale, 0.6f * effectScale);
|
||||
main.startColor = foodColor;
|
||||
main.gravityModifier = -0.05f; // Slight float
|
||||
main.maxParticles = foodParticleCount;
|
||||
main.simulationSpace = ParticleSystemSimulationSpace.World;
|
||||
|
||||
var emission = ps.emission;
|
||||
emission.enabled = true;
|
||||
emission.rateOverTime = 0;
|
||||
// Burst
|
||||
emission.SetBursts(new ParticleSystem.Burst[] { new ParticleSystem.Burst(0f, (short)foodParticleCount) });
|
||||
|
||||
var shape = ps.shape;
|
||||
shape.enabled = true;
|
||||
shape.shapeType = ParticleSystemShapeType.Sphere;
|
||||
shape.radius = 0.4f * effectScale;
|
||||
|
||||
var sizeOverLifetime = ps.sizeOverLifetime;
|
||||
sizeOverLifetime.enabled = true;
|
||||
AnimationCurve sizeCurve = new AnimationCurve();
|
||||
sizeCurve.AddKey(0f, 0.2f);
|
||||
sizeCurve.AddKey(0.5f, 1f);
|
||||
sizeCurve.AddKey(1f, 0f);
|
||||
sizeOverLifetime.size = new ParticleSystem.MinMaxCurve(1f, sizeCurve);
|
||||
|
||||
var colorOverLifetime = ps.colorOverLifetime;
|
||||
colorOverLifetime.enabled = true;
|
||||
Gradient gradient = new Gradient();
|
||||
gradient.SetKeys(
|
||||
new GradientColorKey[] { new GradientColorKey(foodColor, 0f), new GradientColorKey(foodColor, 1f) },
|
||||
new GradientAlphaKey[] { new GradientAlphaKey(0.8f, 0f), new GradientAlphaKey(1f, 0.2f), new GradientAlphaKey(0f, 1f) }
|
||||
);
|
||||
colorOverLifetime.color = gradient;
|
||||
|
||||
var renderer = go.GetComponent<ParticleSystemRenderer>();
|
||||
renderer.renderMode = ParticleSystemRenderMode.Billboard;
|
||||
renderer.material = CreateParticleMaterial(foodColor);
|
||||
|
||||
return ps;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a simple additive particle material.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user