Add complete AI Director system that transforms the survival simulation into a user-driven interactive story with audience voting. Backend: - Add DirectorService for LLM-powered plot generation with fallback templates - Add VoteManager for dual-channel voting (Twitch + Unity) - Integrate 4-phase game loop: Simulation → Narrative → Voting → Resolution - Add vote command parsing (!1, !2, !A, !B) in Twitch service - Add type-safe LLM output handling with _coerce_int() helper - Normalize voter IDs for case-insensitive duplicate prevention Unity Client: - Add NarrativeUI for cinematic event cards and voting progress bars - Add 7 new event types and data models for director/voting events - Add delayed subscription coroutine for NetworkManager timing - Sync client timer with server's remaining_seconds to prevent drift Documentation: - Update README.md with AI Director features, voting commands, and event types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
589 lines
19 KiB
C#
589 lines
19 KiB
C#
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
|
|
}
|
|
}
|