531 lines
22 KiB
C#
531 lines
22 KiB
C#
using UnityEngine;
|
||
using UnityEngine.UI;
|
||
using Subsystems;
|
||
using GeoSus.Client;
|
||
using System.Collections.Generic;
|
||
using System;
|
||
using TMPro;
|
||
|
||
namespace Subsystems
|
||
{
|
||
/// <summary>
|
||
/// Reads from GameManager_Network.State (the authoritative GameState) and drives
|
||
/// all in-game canvas panels. No business logic lives here.
|
||
/// </summary>
|
||
public class GameManager_UI
|
||
{
|
||
private GameClient _gameClient;
|
||
private GameState _state => GameManager.Instance?.networkSubsystem?.State;
|
||
|
||
// ── Canvas refs (wired by BindClientScene from Client.unity) ──────────
|
||
public Canvas ClientCreateJoinLobby;
|
||
public Canvas ClientInLobby;
|
||
public Canvas ClientLoadingScreen;
|
||
public Canvas ClientGameScreen;
|
||
|
||
// ── HUD element refs (resolved once in BindClientScene) ───────────────
|
||
private TMP_Text _roleText;
|
||
private TMP_Text _taskListText;
|
||
private TMP_Text _taskProgressText;
|
||
private Button _actionButton;
|
||
private TMP_Text _actionButtonText;
|
||
private TMP_Text _killCooldownText;
|
||
private GameObject _sabotagePanel;
|
||
private TMP_Text _sabotageTimerText;
|
||
private GameObject _meetingPanel;
|
||
private TMP_Text _meetingHeader;
|
||
private Transform _meetingScrollContent;
|
||
private TMP_Text _meetingFallbackText;
|
||
private GameObject _voteResultPanel;
|
||
private TMP_Text _voteResultText;
|
||
private GameObject _gameEndPanel;
|
||
private TMP_Text _gameEndText;
|
||
private RectTransform _returnToLobbyBtn;
|
||
private TMP_Text _toastText;
|
||
private GameObject _toastGO;
|
||
|
||
// ── Internal state ────────────────────────────────────────────────────
|
||
private bool _isDead;
|
||
private bool _commsBlackout;
|
||
private DateTime _sabotageMeltdownDeadline;
|
||
private bool _sabotageTimerActive;
|
||
private volatile bool _lobbyDirty;
|
||
|
||
// Meeting vote-row references rebuilt each meeting
|
||
private readonly List<GameObject> _voteRows = new List<GameObject>();
|
||
private string _pendingVoteResultDisplay; // shown after voting
|
||
|
||
public GameManager_UI(GameClient gameClient) { _gameClient = gameClient; }
|
||
|
||
public void NotifyLobbyChanged() => _lobbyDirty = true;
|
||
public bool IsCommsBlackout => _commsBlackout;
|
||
public bool IsPlayerDead => _isDead;
|
||
|
||
// ── Scene binding ─────────────────────────────────────────────────────
|
||
|
||
public void BindClientScene(Canvas createJoin, Canvas inLobby, Canvas loading, Canvas game)
|
||
{
|
||
ClientCreateJoinLobby = createJoin;
|
||
ClientInLobby = inLobby;
|
||
ClientLoadingScreen = loading;
|
||
ClientGameScreen = game;
|
||
|
||
foreach (var c in new[] { createJoin, inLobby, loading, game })
|
||
EnsureCanvasReady(c);
|
||
|
||
if (createJoin) createJoin.gameObject.SetActive(false);
|
||
if (inLobby) inLobby.gameObject.SetActive(false);
|
||
if (loading) loading.gameObject.SetActive(false);
|
||
if (game) game.gameObject.SetActive(false);
|
||
|
||
if (game == null) return;
|
||
var t = game.transform;
|
||
|
||
_roleText = FindTMP(t, "Role");
|
||
_taskListText = FindTMP(t, "TaskList");
|
||
_taskProgressText = FindTMP(t, "TaskProgress");
|
||
_killCooldownText = FindTMP(t, "KillCooldown");
|
||
_sabotageTimerText = FindTMP(t, "SabotageTimer");
|
||
_gameEndText = FindTMP(t, "GameEndText");
|
||
_toastText = FindTMP(t, "Toast");
|
||
_meetingHeader = FindTMP(t, "MeetingHeader");
|
||
_meetingFallbackText = FindTMP(t, "MeetingPlayerList");
|
||
_voteResultText = FindTMP(t, "VoteResult");
|
||
_meetingScrollContent = FindTransform(t, "MeetingContent");
|
||
|
||
var actionGO = t.Find("ActionButton");
|
||
if (actionGO != null)
|
||
{
|
||
_actionButton = actionGO.GetComponent<Button>();
|
||
_actionButtonText = actionGO.GetComponentInChildren<TMP_Text>();
|
||
}
|
||
|
||
_sabotagePanel = t.Find("SabotagePanel")?.gameObject;
|
||
_meetingPanel = t.Find("MeetingPanel")?.gameObject;
|
||
_gameEndPanel = t.Find("GameEndPanel")?.gameObject;
|
||
_voteResultPanel = FindTransformGO(t, "VoteResultPanel");
|
||
_toastGO = FindTransformGO(t, "Toast");
|
||
|
||
var retBtn = FindTransform(t, "ReturnToLobbyButton");
|
||
if (retBtn != null) _returnToLobbyBtn = retBtn as RectTransform;
|
||
|
||
if (_meetingPanel) _meetingPanel.SetActive(false);
|
||
if (_gameEndPanel) _gameEndPanel.SetActive(false);
|
||
if (_voteResultPanel) _voteResultPanel.SetActive(false);
|
||
if (_toastGO) _toastGO.SetActive(false);
|
||
}
|
||
|
||
// ── Update (called every frame from GameManager.Update) ───────────────
|
||
|
||
public void UpdateLobbyUI()
|
||
{
|
||
var lobbyState = _gameClient.CurrentLobbyState;
|
||
if (lobbyState == null) return;
|
||
|
||
if (_lobbyDirty)
|
||
{
|
||
_lobbyDirty = false;
|
||
LobbyDisplayUI.RefreshAll(lobbyState);
|
||
}
|
||
|
||
if (ClientGameScreen == null) return;
|
||
|
||
switch (lobbyState.Phase)
|
||
{
|
||
case GamePhase.Loading:
|
||
SetCanvases(false, false, true, false);
|
||
break;
|
||
case GamePhase.Lobby:
|
||
SetCanvases(false, true, false, false);
|
||
break;
|
||
case GamePhase.Playing:
|
||
case GamePhase.Meeting:
|
||
case GamePhase.Voting:
|
||
SetCanvases(false, false, false, true);
|
||
UpdateGameHUD();
|
||
break;
|
||
case GamePhase.Ended:
|
||
SetCanvases(false, false, false, true);
|
||
break;
|
||
}
|
||
|
||
TickToast();
|
||
}
|
||
|
||
// ── Game HUD tick ─────────────────────────────────────────────────────
|
||
|
||
private void UpdateGameHUD()
|
||
{
|
||
var s = _state;
|
||
if (s == null) return;
|
||
|
||
// Role
|
||
if (_roleText != null)
|
||
{
|
||
string ghostSuffix = s.IsDead ? " (GHOST)" : "";
|
||
_roleText.text = $"{s.MyRole?.ToString() ?? "?"}{ghostSuffix}";
|
||
_roleText.color = s.MyRole == PlayerRole.Impostor ? new Color(0.9f,0.2f,0.2f) : new Color(0.2f,0.8f,1f);
|
||
}
|
||
|
||
// Task list with checkmarks
|
||
if (_taskListText != null)
|
||
{
|
||
var sb = new System.Text.StringBuilder();
|
||
foreach (var task in s.MyTasks)
|
||
{
|
||
bool done = s.MyCompletedTaskIds.Contains(task.TaskId);
|
||
string mark = done ? "<color=#2DB84B>✓</color>" : "○";
|
||
sb.AppendLine($"{mark} {task.Name}");
|
||
}
|
||
_taskListText.text = sb.ToString();
|
||
}
|
||
|
||
// Global task progress
|
||
if (_taskProgressText != null && s.TotalRequired > 0)
|
||
_taskProgressText.text = $"Tasks: {s.TotalCompleted}/{s.TotalRequired}";
|
||
|
||
// Kill cooldown
|
||
if (_killCooldownText != null)
|
||
{
|
||
bool show = s.KillCooldownRemaining > 0;
|
||
_killCooldownText.gameObject.SetActive(show);
|
||
if (show) _killCooldownText.text = $"Kill: {Mathf.CeilToInt(s.KillCooldownRemaining)}s";
|
||
}
|
||
|
||
// Sabotage banner
|
||
if (_sabotageTimerActive && _sabotageTimerText != null)
|
||
{
|
||
double remaining = (_sabotageMeltdownDeadline - DateTime.UtcNow).TotalSeconds;
|
||
_sabotageTimerText.text = remaining > 0 ? $"⚠ MELTDOWN: {remaining:F0}s" : "⚠ MELTDOWN!";
|
||
}
|
||
|
||
// Keep meeting voted-indicator rows fresh each frame
|
||
TickMeetingVoteIndicators();
|
||
}
|
||
|
||
// ── Kill cooldown helper (called from GameManager) ────────────────────
|
||
|
||
public void SetKillCooldownText(string text)
|
||
{
|
||
if (_killCooldownText == null) return;
|
||
bool show = !string.IsNullOrEmpty(text);
|
||
_killCooldownText.gameObject.SetActive(show);
|
||
if (show) _killCooldownText.text = text;
|
||
}
|
||
|
||
public void UpdateTaskProgress(int completed, int total)
|
||
{
|
||
if (_taskProgressText != null)
|
||
_taskProgressText.text = $"Tasks: {completed}/{total}";
|
||
}
|
||
|
||
// ── Action button ─────────────────────────────────────────────────────
|
||
|
||
public void SetActionButton(string label, bool visible, UnityEngine.Events.UnityAction onClick = null)
|
||
{
|
||
if (_actionButton == null) return;
|
||
_actionButton.gameObject.SetActive(visible);
|
||
if (_actionButtonText != null) _actionButtonText.text = label;
|
||
if (onClick != null)
|
||
{
|
||
_actionButton.onClick.RemoveAllListeners();
|
||
_actionButton.onClick.AddListener(onClick);
|
||
}
|
||
}
|
||
|
||
// ── Player state ──────────────────────────────────────────────────────
|
||
|
||
public void OnLocalPlayerDied()
|
||
{
|
||
_isDead = true;
|
||
if (_state != null) _state.IsDead = true;
|
||
}
|
||
|
||
// ── Meeting ───────────────────────────────────────────────────────────
|
||
|
||
public void ShowMeetingAlert()
|
||
{
|
||
ShowToast("⚠ Meeting called! Head to the meeting point.");
|
||
}
|
||
|
||
public void ShowMeetingPanel(List<PlayerInfo> players, MeetingStartedPayload payload)
|
||
{
|
||
if (_meetingPanel == null) return;
|
||
_meetingPanel.SetActive(true);
|
||
|
||
if (_meetingHeader != null)
|
||
_meetingHeader.text = payload.Type == MeetingType.BodyReport ? "BODY REPORTED!" : "EMERGENCY MEETING!";
|
||
|
||
if (_voteResultPanel) _voteResultPanel.SetActive(false);
|
||
|
||
BuildMeetingVoteRows(players);
|
||
}
|
||
|
||
private void BuildMeetingVoteRows(List<PlayerInfo> players)
|
||
{
|
||
// Clear old rows
|
||
foreach (var r in _voteRows) if (r) UnityEngine.Object.Destroy(r);
|
||
_voteRows.Clear();
|
||
|
||
if (_meetingScrollContent == null || players == null)
|
||
{
|
||
// Fall back to text list
|
||
if (_meetingFallbackText != null)
|
||
{
|
||
_meetingFallbackText.gameObject.SetActive(true);
|
||
var sb = new System.Text.StringBuilder();
|
||
foreach (var p in players ?? new List<PlayerInfo>())
|
||
sb.AppendLine($"{p.DisplayName} [{p.State}]");
|
||
_meetingFallbackText.text = sb.ToString();
|
||
}
|
||
return;
|
||
}
|
||
|
||
string myId = _gameClient.ClientUuid;
|
||
bool canVote = !_isDead;
|
||
|
||
foreach (var player in players)
|
||
{
|
||
bool isMe = player.ClientUuid == myId;
|
||
bool isAlive = player.State == PlayerState.Alive;
|
||
var row = BuildVoteRow(player, isMe, isAlive, canVote && isAlive && !isMe);
|
||
row.transform.SetParent(_meetingScrollContent, false);
|
||
_voteRows.Add(row);
|
||
}
|
||
}
|
||
|
||
private GameObject BuildVoteRow(PlayerInfo player, bool isMe, bool isAlive, bool canVote)
|
||
{
|
||
const float ROW_H = 110f;
|
||
var go = new GameObject($"VoteRow_{player.ClientUuid}");
|
||
var rt = go.AddComponent<RectTransform>();
|
||
rt.sizeDelta = new Vector2(0, ROW_H);
|
||
var le = go.AddComponent<LayoutElement>();
|
||
le.minHeight = le.preferredHeight = ROW_H;
|
||
|
||
var bg = go.AddComponent<Image>();
|
||
bg.color = isMe ? new Color(0.12f,0.18f,0.30f) : new Color(0.10f,0.12f,0.20f);
|
||
|
||
// Dead overlay
|
||
if (!isAlive)
|
||
{
|
||
bg.color = new Color(0.08f,0.08f,0.10f,0.7f);
|
||
}
|
||
|
||
// Name label
|
||
var namRT = MakeChild("Name", rt);
|
||
namRT.anchorMin = new Vector2(0,0); namRT.anchorMax = new Vector2(0.65f,1);
|
||
namRT.offsetMin = new Vector2(16,6); namRT.offsetMax = new Vector2(0,-6);
|
||
var namTmp = namRT.gameObject.AddComponent<TextMeshProUGUI>();
|
||
namTmp.text = (player.IsOwner ? "👑 " : "") + (player.DisplayName ?? "???");
|
||
namTmp.fontSize = 36;
|
||
namTmp.color = !isAlive ? Color.gray : (isMe ? Color.white : new Color(0.73f,0.8f,0.88f));
|
||
namTmp.fontStyle = isMe ? FontStyles.Bold : FontStyles.Normal;
|
||
namTmp.alignment = TextAlignmentOptions.MidlineLeft;
|
||
|
||
// Vote button
|
||
var voteBtnRT = MakeChild("VoteBtn", rt);
|
||
voteBtnRT.anchorMin = new Vector2(0.68f,0.10f); voteBtnRT.anchorMax = new Vector2(0.95f,0.90f);
|
||
var voteBg = voteBtnRT.gameObject.AddComponent<Image>();
|
||
voteBg.color = canVote ? new Color(0.2f,0.6f,1f) : new Color(0.2f,0.2f,0.2f,0.5f);
|
||
var voteBtn = voteBtnRT.gameObject.AddComponent<Button>();
|
||
voteBtn.targetGraphic = voteBg;
|
||
voteBtn.interactable = canVote;
|
||
string capturedId = player.ClientUuid;
|
||
voteBtn.onClick.AddListener(() => GameManager.Instance?.CastVote(capturedId));
|
||
var voteTxtRT = MakeChild("Txt", voteBtnRT);
|
||
Stretch(voteTxtRT);
|
||
var voteTmp = voteTxtRT.gameObject.AddComponent<TextMeshProUGUI>();
|
||
voteTmp.text = isAlive ? "VOTE" : "DEAD";
|
||
voteTmp.fontSize = 28;
|
||
voteTmp.fontStyle = FontStyles.Bold;
|
||
voteTmp.color = Color.white;
|
||
voteTmp.alignment = TextAlignmentOptions.Center;
|
||
|
||
// Voted indicator (hidden by default; shown by TickMeetingVoteIndicators)
|
||
var votedRT = MakeChild("VotedTick", rt);
|
||
votedRT.anchorMin = new Vector2(0.95f,0.20f); votedRT.anchorMax = new Vector2(1f,0.80f);
|
||
var vtTmp = votedRT.gameObject.AddComponent<TextMeshProUGUI>();
|
||
vtTmp.text = "✓"; vtTmp.fontSize = 34;
|
||
vtTmp.color = new Color(0.18f,0.75f,0.30f); vtTmp.alignment = TextAlignmentOptions.Center;
|
||
votedRT.gameObject.SetActive(false);
|
||
|
||
return go;
|
||
}
|
||
|
||
private void TickMeetingVoteIndicators()
|
||
{
|
||
var s = _state;
|
||
if (s == null) return;
|
||
foreach (var row in _voteRows)
|
||
{
|
||
if (row == null) continue;
|
||
// Row name is "VoteRow_<uuid>"
|
||
string uuid = row.name.Replace("VoteRow_", "");
|
||
var tick = row.transform.Find("VotedTick")?.gameObject;
|
||
if (tick != null)
|
||
tick.SetActive(s.VotedPlayerIds.Contains(uuid));
|
||
}
|
||
}
|
||
|
||
public void AppendVoteInstruction()
|
||
{
|
||
// no-op – vote instructions are embedded in the row buttons
|
||
}
|
||
|
||
public void ShowVoteResult(VotingClosedPayload payload, List<PlayerInfo> players)
|
||
{
|
||
if (_voteResultPanel != null) _voteResultPanel.SetActive(true);
|
||
if (_voteResultText != null)
|
||
{
|
||
if (payload.WasTie)
|
||
_voteResultText.text = "⚖ TIE — nobody ejected.";
|
||
else if (string.IsNullOrEmpty(payload.EjectedPlayerId))
|
||
_voteResultText.text = "Nobody ejected (skip).";
|
||
else
|
||
{
|
||
var ej = players?.Find(p => p.ClientUuid == payload.EjectedPlayerId);
|
||
_voteResultText.text = $"🚪 {ej?.DisplayName ?? payload.EjectedPlayerId} ejected!";
|
||
}
|
||
}
|
||
|
||
// Auto-close meeting panel after 5 s
|
||
GameManager.Instance?.StartCoroutine(CloseMeetingAfterDelay(5f));
|
||
}
|
||
|
||
private System.Collections.IEnumerator CloseMeetingAfterDelay(float delay)
|
||
{
|
||
yield return new UnityEngine.WaitForSeconds(delay);
|
||
if (_meetingPanel) _meetingPanel.SetActive(false);
|
||
if (_voteResultPanel) _voteResultPanel.SetActive(false);
|
||
}
|
||
|
||
// ── Sabotage ──────────────────────────────────────────────────────────
|
||
|
||
public void ShowSabotageTimer(DateTime deadline)
|
||
{
|
||
_sabotageMeltdownDeadline = deadline;
|
||
_sabotageTimerActive = true;
|
||
if (_sabotagePanel) _sabotagePanel.SetActive(true);
|
||
if (_sabotageTimerText) _sabotageTimerText.gameObject.SetActive(true);
|
||
}
|
||
|
||
public void HideSabotageTimer()
|
||
{
|
||
_sabotageTimerActive = false;
|
||
if (_sabotagePanel) _sabotagePanel.SetActive(false);
|
||
SetCommsBlackout(false);
|
||
}
|
||
|
||
public void SetCommsBlackout(bool active) => _commsBlackout = active;
|
||
|
||
// ── Game end ──────────────────────────────────────────────────────────
|
||
|
||
public void ShowGameEndPanel(GameEndedPayload payload, string myUuid)
|
||
{
|
||
if (_gameEndPanel) _gameEndPanel.SetActive(true);
|
||
if (_gameEndText != null)
|
||
{
|
||
bool won = payload.Winners?.Contains(myUuid) ?? false;
|
||
string title = won ? "<color=#FFB800>🏆 VICTORY</color>" : "<color=#C43232>💔 DEFEAT</color>";
|
||
string faction = payload.WinningFaction == "Impostor" ? "Impostors win!" : "Crew wins!";
|
||
_gameEndText.text = $"{title}\n{faction}\n<size=38>{payload.Reason}</size>";
|
||
}
|
||
|
||
// Show "Return to Lobby" only for the host
|
||
if (_returnToLobbyBtn != null)
|
||
_returnToLobbyBtn.gameObject.SetActive(_gameClient.IsOwner);
|
||
}
|
||
|
||
// ── Toast ─────────────────────────────────────────────────────────────
|
||
|
||
public void ShowToast(string message)
|
||
{
|
||
if (_state != null) { _state.ToastMessage = message; _state.ToastExpiry = UnityEngine.Time.time + 4f; }
|
||
if (_toastGO == null) return;
|
||
_toastGO.SetActive(true);
|
||
if (_toastText != null) _toastText.text = message;
|
||
}
|
||
|
||
private void TickToast()
|
||
{
|
||
var s = _state;
|
||
if (_toastGO == null) return;
|
||
|
||
if (s != null && !string.IsNullOrEmpty(s.ToastMessage) && UnityEngine.Time.time < s.ToastExpiry)
|
||
{
|
||
_toastGO.SetActive(true);
|
||
if (_toastText != null) _toastText.text = s.ToastMessage;
|
||
}
|
||
else
|
||
{
|
||
_toastGO.SetActive(false);
|
||
}
|
||
}
|
||
|
||
// ── Canvas switching ──────────────────────────────────────────────────
|
||
|
||
private void SetCanvases(bool createJoin, bool inLobby, bool loading, bool game)
|
||
{
|
||
EnsureCanvasReady(ClientCreateJoinLobby);
|
||
EnsureCanvasReady(ClientInLobby);
|
||
EnsureCanvasReady(ClientLoadingScreen);
|
||
EnsureCanvasReady(ClientGameScreen);
|
||
|
||
if (ClientCreateJoinLobby) ClientCreateJoinLobby.gameObject.SetActive(createJoin);
|
||
if (ClientInLobby) ClientInLobby.gameObject.SetActive(inLobby);
|
||
if (ClientLoadingScreen) ClientLoadingScreen.gameObject.SetActive(loading);
|
||
if (ClientGameScreen) ClientGameScreen.gameObject.SetActive(game);
|
||
}
|
||
|
||
// ── Utilities ─────────────────────────────────────────────────────────
|
||
|
||
private static void EnsureCanvasReady(Canvas canvas)
|
||
{
|
||
if (canvas == null) return;
|
||
if (!canvas.enabled) canvas.enabled = true;
|
||
var t = canvas.transform;
|
||
if (t != null)
|
||
{
|
||
var s = t.localScale;
|
||
if (Mathf.Abs(s.x) < 0.001f || Mathf.Abs(s.y) < 0.001f || Mathf.Abs(s.z) < 0.001f)
|
||
t.localScale = Vector3.one;
|
||
}
|
||
}
|
||
|
||
private static TMP_Text FindTMP(Transform root, string name)
|
||
{
|
||
if (root == null) return null;
|
||
foreach (var tmp in root.GetComponentsInChildren<TMP_Text>(true))
|
||
if (tmp != null && tmp.name == name) return tmp;
|
||
return null;
|
||
}
|
||
|
||
private static Transform FindTransform(Transform root, string name)
|
||
{
|
||
if (root == null) return null;
|
||
foreach (Transform t in root.GetComponentsInChildren<Transform>(true))
|
||
if (t.name == name) return t;
|
||
return null;
|
||
}
|
||
|
||
private static GameObject FindTransformGO(Transform root, string name)
|
||
=> FindTransform(root, name)?.gameObject;
|
||
|
||
private static RectTransform MakeChild(string name, RectTransform parent)
|
||
{
|
||
var go = new GameObject(name);
|
||
var rt = go.AddComponent<RectTransform>();
|
||
rt.SetParent(parent, false);
|
||
rt.localScale = Vector3.one;
|
||
return rt;
|
||
}
|
||
|
||
private static void Stretch(RectTransform rt)
|
||
{
|
||
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
|
||
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
|
||
}
|
||
}
|
||
}
|